feat: align portal center panel with side cards + redesign login page
- Portal: wrap .panel-head in matching card (border, radius, shadow) with cyan->purple left accent bar; h2 28px -> 20px; description right-aligned with vertical divider; top edge now aligns with side-menu & links-panel - Login: switch from absolute-positioned icons to el-input prefix slot; layout brand row (logo + title + uppercase subtitle); add form-title section with gradient bar; consistent cyan focus; gradient button; dashed top divider for back-link; card width 420 -> 460 - Add .gitignore (node_modules, dist, __pycache__, .env, etc.)
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""Public portal API — no auth required."""
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from models import Category, Option, Resource, SideLink, SiteSetting
|
||||
|
||||
bp = Blueprint("portal", __name__, url_prefix="/api/portal")
|
||||
|
||||
|
||||
@bp.route("/bootstrap")
|
||||
def bootstrap():
|
||||
"""Everything the portal page needs in one request."""
|
||||
settings = {s.key: s.value for s in SiteSetting.query.all()}
|
||||
categories = (
|
||||
Category.query.filter_by(is_active=True)
|
||||
.order_by(Category.sort_order, Category.id)
|
||||
.all()
|
||||
)
|
||||
links = (
|
||||
SideLink.query.filter_by(is_active=True)
|
||||
.order_by(SideLink.sort_order, SideLink.id)
|
||||
.all()
|
||||
)
|
||||
return jsonify({
|
||||
"settings": settings,
|
||||
"categories": [c.to_dict() for c in categories],
|
||||
"links": [l.to_dict() for l in links],
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/categories/<int:cid>/resources")
|
||||
def category_resources(cid):
|
||||
"""Resources for a category, faceted by selected option ids."""
|
||||
category = Category.query.get_or_404(cid)
|
||||
selected = []
|
||||
raw = request.args.get("option_ids", "")
|
||||
keyword = request.args.get("keyword", "").strip()
|
||||
if raw.strip():
|
||||
try:
|
||||
selected = [int(x) for x in raw.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
selected = []
|
||||
|
||||
query = Resource.query.filter_by(category_id=cid, is_active=True)
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
Resource.title.like(f"%{keyword}%") |
|
||||
Resource.description.like(f"%{keyword}%") |
|
||||
Resource.content.like(f"%{keyword}%")
|
||||
)
|
||||
resources = query.order_by(Resource.sort_order, Resource.id).all()
|
||||
|
||||
if selected:
|
||||
chosen = Option.query.filter(Option.id.in_(selected)).all()
|
||||
by_row = {}
|
||||
for o in chosen:
|
||||
by_row.setdefault(o.row_id, []).append(o.id)
|
||||
|
||||
def matches(res):
|
||||
tagged_ids = {o.id for o in res.options}
|
||||
tagged_row_ids = {o.row_id for o in res.options}
|
||||
for row_id, opt_ids in by_row.items():
|
||||
if row_id not in tagged_row_ids:
|
||||
continue # untagged in this row -> applies to all
|
||||
if not tagged_ids.intersection(opt_ids):
|
||||
return False
|
||||
return True
|
||||
|
||||
resources = [r for r in resources if matches(r)]
|
||||
|
||||
return jsonify({
|
||||
"category": category.to_dict(),
|
||||
"resources": [r.to_dict() for r in resources],
|
||||
"selected_option_ids": selected,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/search")
|
||||
def global_search():
|
||||
"""Global search across all resources."""
|
||||
keyword = request.args.get("keyword", "").strip()
|
||||
if not keyword or len(keyword) < 2:
|
||||
return jsonify({"results": [], "keyword": keyword})
|
||||
|
||||
results = Resource.query.filter_by(is_active=True).filter(
|
||||
Resource.title.like(f"%{keyword}%") |
|
||||
Resource.description.like(f"%{keyword}%") |
|
||||
Resource.content.like(f"%{keyword}%")
|
||||
).order_by(Resource.category_id, Resource.sort_order).limit(50).all()
|
||||
|
||||
return jsonify({
|
||||
"keyword": keyword,
|
||||
"count": len(results),
|
||||
"results": [r.to_dict() for r in results]
|
||||
})
|
||||
Reference in New Issue
Block a user