refactor: remove duplicated route and UI code

This commit is contained in:
2026-07-06 17:14:09 +08:00
parent f385f9747b
commit 2fdbaa8876
10 changed files with 1362 additions and 300 deletions
+3
View File
@@ -8,6 +8,9 @@ __pycache__/
.venv/ .venv/
venv/ venv/
# Frontend dependencies
node_modules/
# Runtime logs # Runtime logs
*.log *.log
server.out.log server.out.log
+73 -51
View File
@@ -31,6 +31,21 @@ REGISTRATION_SETTING_KEY = "allow_registration"
RECORDS_PER_PAGE = 10 RECORDS_PER_PAGE = 10
def render_auth_template(mode: str, status_code: int = 200, captcha: str = ""):
return render_template("login.html", mode=mode, captcha=captcha), status_code
def render_login_error(message: str, status_code: int = 400):
flash(message, "error")
session["captcha"] = new_captcha()
return render_auth_template("login", status_code, session["captcha"])
def require_admin() -> None:
if not current_user.is_admin:
abort(403)
def registration_allowed() -> bool: def registration_allowed() -> bool:
return AppSetting.get_bool( return AppSetting.get_bool(
REGISTRATION_SETTING_KEY, REGISTRATION_SETTING_KEY,
@@ -45,6 +60,37 @@ def requested_page() -> int:
return 1 return 1
def paginated_uploads(query, endpoint: str):
page = requested_page()
pagination = (
query.order_by(UploadRecord.upload_time.desc())
.paginate(page=page, per_page=RECORDS_PER_PAGE, error_out=False)
)
if pagination.pages and page > pagination.pages:
return pagination, redirect(url_for(endpoint, page=pagination.pages))
return pagination, None
def prediction_result_payload(artifacts, record: UploadRecord) -> dict:
image_url = url_for("static", filename=f"images/{artifacts.image_filename}")
importance_url = (
url_for("static", filename=f"images/{artifacts.importance_filename}")
if artifacts.importance_filename
else None
)
return {
"original_filename": artifacts.original_filename,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"image_url": image_url,
"importance_url": importance_url,
"excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"),
"result_url": url_for("main.result_page"),
"sample_count": int(artifacts.sample_count),
"summary_rows": artifacts.summary_rows[:6],
"analysis_text": artifacts.analysis_text,
}
@bp.route("/") @bp.route("/")
def index(): def index():
if current_user.is_authenticated: if current_user.is_authenticated:
@@ -59,22 +105,18 @@ def login():
if request.method == "GET": if request.method == "GET":
session["captcha"] = new_captcha() session["captcha"] = new_captcha()
return render_template("login.html", mode="login", captcha=session["captcha"]) return render_auth_template("login", captcha=session["captcha"])
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
captcha_input = request.form.get("captcha", "").strip().upper() captcha_input = request.form.get("captcha", "").strip().upper()
if captcha_input != session.get("captcha", ""): if captcha_input != session.get("captcha", ""):
flash("验证码错误", "error") return render_login_error("验证码错误")
session["captcha"] = new_captcha()
return render_template("login.html", mode="login", captcha=session["captcha"]), 400
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password): if not user or not user.check_password(password):
flash("用户名或密码错误", "error") return render_login_error("用户名或密码错误")
session["captcha"] = new_captcha()
return render_template("login.html", mode="login", captcha=session["captcha"]), 400
login_user(user, remember=bool(request.form.get("remember"))) login_user(user, remember=bool(request.form.get("remember")))
return redirect(url_for("main.home")) return redirect(url_for("main.home"))
@@ -83,24 +125,24 @@ def login():
@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register", methods=["GET", "POST"])
def register(): def register():
if request.method == "GET": if request.method == "GET":
return render_template("login.html", mode="register", captcha="") return render_auth_template("register")
if not registration_allowed(): if not registration_allowed():
flash("当前未开放自助注册,请联系管理员。", "error") flash("当前未开放自助注册,请联系管理员。", "error")
return render_template("login.html", mode="register", captcha=""), 403 return render_auth_template("register", 403)
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
if not username: if not username:
flash("用户名不能为空", "error") flash("用户名不能为空", "error")
return render_template("login.html", mode="register", captcha=""), 400 return render_auth_template("register", 400)
if len(password) < 6: if len(password) < 6:
flash("密码至少需要 6 位", "error") flash("密码至少需要 6 位", "error")
return render_template("login.html", mode="register", captcha=""), 400 return render_auth_template("register", 400)
if User.query.filter_by(username=username).first(): if User.query.filter_by(username=username).first():
flash("用户名已存在", "error") flash("用户名已存在", "error")
return render_template("login.html", mode="register", captcha=""), 400 return render_auth_template("register", 400)
user = User(username=username, is_admin=False) user = User(username=username, is_admin=False)
user.set_password(password) user.set_password(password)
@@ -108,7 +150,7 @@ def register():
db.session.commit() db.session.commit()
flash("注册成功,请登录", "info") flash("注册成功,请登录", "info")
session["captcha"] = new_captcha() session["captcha"] = new_captcha()
return render_template("login.html", mode="login", captcha=session["captcha"]) return render_auth_template("login", captcha=session["captcha"])
@bp.route("/logout", methods=["POST"]) @bp.route("/logout", methods=["POST"])
@@ -127,14 +169,12 @@ def home():
@bp.route("/history") @bp.route("/history")
@login_required @login_required
def history_page(): def history_page():
page = requested_page() pagination, page_redirect = paginated_uploads(
pagination = ( UploadRecord.query.filter_by(user_id=current_user.id),
UploadRecord.query.filter_by(user_id=current_user.id) "main.history_page",
.order_by(UploadRecord.upload_time.desc())
.paginate(page=page, per_page=RECORDS_PER_PAGE, error_out=False)
) )
if pagination.pages and page > pagination.pages: if page_redirect:
return redirect(url_for("main.history_page", page=pagination.pages)) return page_redirect
return render_template( return render_template(
"history.html", "history.html",
pagination=pagination, pagination=pagination,
@@ -145,16 +185,13 @@ def history_page():
@bp.route("/admin") @bp.route("/admin")
@login_required @login_required
def admin_dashboard(): def admin_dashboard():
if not current_user.is_admin: require_admin()
abort(403) pagination, page_redirect = paginated_uploads(
page = requested_page() UploadRecord.query.options(joinedload(UploadRecord.user)),
pagination = ( "main.admin_dashboard",
UploadRecord.query.options(joinedload(UploadRecord.user))
.order_by(UploadRecord.upload_time.desc())
.paginate(page=page, per_page=RECORDS_PER_PAGE, error_out=False)
) )
if pagination.pages and page > pagination.pages: if page_redirect:
return redirect(url_for("main.admin_dashboard", page=pagination.pages)) return page_redirect
return render_template( return render_template(
"admin.html", "admin.html",
pagination=pagination, pagination=pagination,
@@ -166,8 +203,7 @@ def admin_dashboard():
@bp.route("/admin/registration", methods=["POST"]) @bp.route("/admin/registration", methods=["POST"])
@login_required @login_required
def update_registration_setting(): def update_registration_setting():
if not current_user.is_admin: require_admin()
abort(403)
allow_registration = request.form.get("allow_registration") == "on" allow_registration = request.form.get("allow_registration") == "on"
AppSetting.set_bool(REGISTRATION_SETTING_KEY, allow_registration) AppSetting.set_bool(REGISTRATION_SETTING_KEY, allow_registration)
@@ -195,11 +231,11 @@ def download_file(record_id: int, file_type: str):
abort(404) abort(404)
if not (current_user.is_admin or current_user.id == record.user_id): if not (current_user.is_admin or current_user.id == record.user_id):
abort(403) abort(403)
if file_type == "original": file_path = {
file_path = record.saved_path "original": record.saved_path,
elif file_type == "prediction": "prediction": record.prediction_path,
file_path = record.prediction_path }.get(file_type)
else: if file_path is None:
abort(404) abort(404)
if not os.path.exists(file_path): if not os.path.exists(file_path):
abort(404) abort(404)
@@ -267,21 +303,7 @@ def predict():
db.session.add(record) db.session.add(record)
db.session.commit() db.session.commit()
last_result = { last_result = prediction_result_payload(artifacts, record)
"original_filename": artifacts.original_filename,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"image_url": url_for("static", filename=f"images/{artifacts.image_filename}"),
"importance_url": (
url_for("static", filename=f"images/{artifacts.importance_filename}")
if artifacts.importance_filename
else None
),
"excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"),
"result_url": url_for("main.result_page"),
"sample_count": int(artifacts.sample_count),
"summary_rows": artifacts.summary_rows[:6],
"analysis_text": artifacts.analysis_text,
}
session["last_result"] = last_result session["last_result"] = last_result
return jsonify( return jsonify(
+1049
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"scripts": {
"build:css": "tailwindcss -i ./static/css/app.src.css -o ./static/css/app.css --minify",
"watch:css": "tailwindcss -i ./static/css/app.src.css -o ./static/css/app.css --watch"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",
"tailwindcss": "^3.4.17"
}
}
File diff suppressed because one or more lines are too long
+176
View File
@@ -0,0 +1,176 @@
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('../fonts/material-symbols-outlined.ttf') format('truetype');
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
h1,
h2,
h3,
h4 {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
}
@layer components {
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-flex;
width: 1em;
height: 1em;
min-width: 1em;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
vertical-align: middle;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.ui-btn {
box-sizing: border-box;
display: inline-flex;
min-height: 44px;
height: 44px;
align-items: center;
justify-content: center;
gap: .5rem;
border-radius: .375rem;
border: 1px solid transparent;
padding: 0 1rem;
font-size: .875rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.ui-btn-lg {
min-height: 52px;
height: 52px;
font-size: .875rem;
}
.ui-btn-sm {
min-height: 40px;
height: 40px;
padding: 0 .75rem;
}
.ui-btn-field {
min-height: 50px;
height: 50px;
}
.ui-btn-primary {
background: #005EB8;
color: #fff;
box-shadow: 0 18px 34px rgba(15, 23, 42, .06);
}
.ui-btn-primary:hover {
background: #0c4188;
}
.ui-btn-secondary {
border-color: #e2e8f0;
background: #fff;
color: #334155;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.ui-btn-secondary:hover {
border-color: #005EB8;
color: #005EB8;
}
.ui-btn:disabled,
.ui-btn[aria-disabled="true"] {
cursor: not-allowed;
box-shadow: none;
opacity: .7;
}
.ui-btn-primary:disabled {
background: #cbd5e1;
}
.ui-btn-icon,
.ui-btn .material-symbols-outlined {
width: 20px;
height: 20px;
flex: 0 0 20px;
font-size: 20px;
}
.ui-icon-btn {
box-sizing: border-box;
display: inline-flex;
width: 28px;
height: 28px;
flex: 0 0 28px;
align-items: center;
justify-content: center;
border-radius: .375rem;
line-height: 1;
transition: background-color .15s ease, color .15s ease;
}
.ui-action-row {
box-sizing: border-box;
display: flex;
min-height: 44px;
align-items: center;
justify-content: space-between;
gap: .75rem;
border-radius: .375rem;
border: 1px solid #e2e8f0;
padding: 0 .75rem;
font-size: .875rem;
font-weight: 700;
line-height: 1;
transition: border-color .15s ease, color .15s ease;
}
.ui-action-row:hover {
border-color: #005EB8;
color: #005EB8;
}
.spinner {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 20px;
border-radius: 9999px;
border: 2px solid rgba(255,255,255,.35);
border-top-color: #fff;
animation: spin .75s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./templates/**/*.html',
'./static/js/**/*.js'
],
theme: {
extend: {
colors: {
primary: '#005EB8',
primaryDeep: '#0c4188',
page: '#f3f5f8',
card: '#ffffff',
line: '#e2e8f0',
textMain: '#0f172a',
textSub: '#64748b',
blueSoft: '#eaf3ff',
bluePanel: '#1d4f9a',
outline: '#c7ced8',
successSoft: '#e9f8ee',
successText: '#16a34a',
warnSoft: '#fff4e8',
warnText: '#c2410c',
dangerSoft: '#fff0f0',
dangerText: '#dc2626',
lowCard: '#f8fafc'
},
fontFamily: {
headline: ['system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif'],
body: ['system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif']
},
boxShadow: {
soft: '0 18px 34px rgba(15, 23, 42, .06)',
panel: '0 1px 2px rgba(15, 23, 42, .04)',
card: '0 10px 25px rgba(15, 23, 42, .06)'
}
}
},
plugins: [
require('@tailwindcss/forms')
]
};
+4 -140
View File
@@ -4,146 +4,7 @@
<title>{% block title %}供水管道健康评估系统{% endblock %}</title> <title>{% block title %}供水管道健康评估系统{% endblock %}</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#005EB8',
primaryDeep: '#0c4188',
page: '#f3f5f8',
card: '#ffffff',
line: '#e2e8f0',
textMain: '#0f172a',
textSub: '#64748b',
blueSoft: '#eaf3ff',
outline: '#c7ced8',
successSoft: '#e9f8ee',
successText: '#16a34a',
dangerSoft: '#fff0f0',
dangerText: '#dc2626'
},
fontFamily: {
headline: ['Manrope', 'Inter', 'sans-serif'],
body: ['Inter', 'sans-serif']
},
boxShadow: {
soft: '0 18px 34px rgba(15, 23, 42, .06)',
panel: '0 1px 2px rgba(15, 23, 42, .04)'
}
}
}
}
</script>
<style>
body { font-family: 'Inter', sans-serif; }
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
.ui-btn {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .5rem;
min-height: 44px;
height: 44px;
border-radius: .375rem;
border: 1px solid transparent;
padding: 0 1rem;
font-size: .875rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.ui-btn-sm {
min-height: 40px;
height: 40px;
padding: 0 .75rem;
}
.ui-btn-primary {
background: #005EB8;
color: #fff;
box-shadow: 0 18px 34px rgba(15, 23, 42, .06);
}
.ui-btn-primary:hover { background: #0c4188; }
.ui-btn-secondary {
border-color: #e2e8f0;
background: #fff;
color: #334155;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.ui-btn-secondary:hover {
border-color: #005EB8;
color: #005EB8;
}
.ui-btn:disabled,
.ui-btn[aria-disabled="true"] {
cursor: not-allowed;
box-shadow: none;
opacity: .7;
}
.ui-btn-primary:disabled { background: #cbd5e1; }
.ui-btn-icon,
.ui-btn .material-symbols-outlined {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 20px;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.ui-icon-btn {
box-sizing: border-box;
display: inline-flex;
width: 28px;
height: 28px;
flex: 0 0 28px;
align-items: center;
justify-content: center;
border-radius: .375rem;
line-height: 1;
transition: background-color .15s ease, color .15s ease;
}
.ui-action-row {
box-sizing: border-box;
display: flex;
min-height: 44px;
align-items: center;
justify-content: space-between;
gap: .75rem;
border-radius: .375rem;
border: 1px solid #e2e8f0;
padding: 0 .75rem;
font-size: .875rem;
font-weight: 700;
line-height: 1;
transition: border-color .15s ease, color .15s ease;
}
.ui-action-row:hover {
border-color: #005EB8;
color: #005EB8;
}
.spinner {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 20px;
border-radius: 9999px;
border: 2px solid rgba(255,255,255,.35);
border-top-color: #fff;
animation: spin .75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
<body class="flex min-h-screen flex-col bg-page text-textMain antialiased"> <body class="flex min-h-screen flex-col bg-page text-textMain antialiased">
@@ -206,6 +67,9 @@
<script> <script>
window.__flashMessages = {{ flashed_messages|tojson }}; window.__flashMessages = {{ flashed_messages|tojson }};
</script> </script>
<div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %}
</div>
{% endwith %} {% endwith %}
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8"> <main class="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
+1 -109
View File
@@ -4,108 +4,8 @@
<title>{{ '注册' if mode == 'register' else '登录' }} | 供水管道健康评估系统</title> <title>{{ '注册' if mode == 'register' else '登录' }} | 供水管道健康评估系统</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <link href="{{ url_for('static', filename='css/app.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#005EB8',
primaryDeep: '#0c4188',
page: '#f3f5f8',
card: '#ffffff',
line: '#e5e7eb',
textMain: '#0f172a',
textSub: '#64748b',
blueSoft: '#eaf3ff',
bluePanel: '#1d4f9a',
outline: '#c7ced8',
successSoft: '#e9f8ee',
successText: '#16a34a',
warnSoft: '#fff4e8',
warnText: '#c2410c',
dangerSoft: '#fff0f0',
dangerText: '#dc2626',
lowCard: '#f8fafc'
},
fontFamily: {
headline: ['Manrope', 'Inter', 'sans-serif'],
body: ['Inter', 'sans-serif']
},
boxShadow: {
soft: '0 24px 24px -12px rgba(24,28,30,.06)',
card: '0 10px 25px rgba(15, 23, 42, .06)'
}
}
}
}
</script>
<style> <style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
.ui-btn {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .5rem;
min-height: 44px;
height: 44px;
border-radius: .375rem;
border: 1px solid transparent;
padding: 0 1rem;
font-size: .875rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.ui-btn-lg {
min-height: 52px;
height: 52px;
font-size: .875rem;
}
.ui-btn-field {
min-height: 50px;
height: 50px;
}
.ui-btn-primary {
background: #005EB8;
color: #fff;
box-shadow: 0 18px 34px rgba(15, 23, 42, .06);
}
.ui-btn-primary:hover { background: #0c4188; }
.ui-btn-secondary {
border-color: #e2e8f0;
background: #fff;
color: #334155;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.ui-btn-secondary:hover {
border-color: #005EB8;
color: #005EB8;
}
.ui-btn:disabled {
cursor: not-allowed;
box-shadow: none;
opacity: .7;
}
.ui-btn-primary:disabled { background: #cbd5e1; }
.ui-btn .material-symbols-outlined {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 20px;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.password-toggle { .password-toggle {
position: absolute; position: absolute;
right: .75rem; right: .75rem;
@@ -144,8 +44,6 @@
background: #fff7f7 !important; background: #fff7f7 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important; box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important;
} }
body { font-family: 'Inter', sans-serif; }
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
.dot-grid { .dot-grid {
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0); background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
background-size: 42px 42px; background-size: 42px 42px;
@@ -157,11 +55,6 @@
.gradient-board { .gradient-board {
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%); background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
} }
.spinner {
width: 18px; height: 18px; border-radius: 9999px;
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
animation: spin .75s linear infinite;
}
.auth-alert { .auth-alert {
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
@@ -203,7 +96,6 @@
transform: translateX(0); transform: translateX(0);
} }
} }
@keyframes spin { to { transform: rotate(360deg); } }
</style> </style>
</head> </head>
<body class="bg-page min-h-screen text-textMain"> <body class="bg-page min-h-screen text-textMain">