Bangkah's picture
Final Fix: upgrade gradio and remove pinned sdk_version
465e11c
Raw
History Blame Contribute Delete
70.9 kB
"""
Warkop Intelligence Finder - Lhokseumawe Edition v4.2
Tema Warkop - cozy, warm, coffeehouse style UI with Gradio
"""
from __future__ import annotations
import logging
from html import escape
from pathlib import Path
from typing import Optional, Tuple
import gradio as gr
import pandas as pd
from models.recommender import WarkopRecommender, WarkopRecommenderError
# =============================================================================
# Setup
# =============================================================================
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent
CSV_PATH = BASE_DIR / "data" / "warkops.csv"
APP_TITLE = "Warkop Intelligence Finder"
APP_SUBTITLE = "Lhokseumawe Edition v4.2"
PRESET_PROFILES = {
"coding": {
"label": "Coding",
"subtitle": "Deep Work",
"weights": {
"wifi_imp": 0.45,
"socket_imp": 0.30,
"quiet_imp": 0.20,
"value_imp": 0.05,
},
},
"social": {
"label": "Social",
"subtitle": "Hangout",
"weights": {
"wifi_imp": 0.15,
"socket_imp": 0.20,
"quiet_imp": 0.05,
"value_imp": 0.60,
},
},
"student": {
"label": "Student",
"subtitle": "Budget",
"weights": {
"wifi_imp": 0.35,
"socket_imp": 0.25,
"quiet_imp": 0.10,
"value_imp": 0.30,
},
},
"premium": {
"label": "Premium",
"subtitle": "Quality",
"weights": {
"wifi_imp": 0.40,
"socket_imp": 0.25,
"quiet_imp": 0.30,
"value_imp": 0.05,
},
},
"chill": {
"label": "Chill",
"subtitle": "Relax",
"weights": {
"wifi_imp": 0.10,
"socket_imp": 0.15,
"quiet_imp": 0.45,
"value_imp": 0.30,
},
},
}
PRICE_RANGES = ["Cheap", "Medium", "Expensive"]
LEVEL_BARS = {"Low": 1, "Medium": 2, "High": 3}
PRICE_SYMBOLS = {"Cheap": "$","Medium": "$$","Expensive": "$$$",}
REQUIRED_COLUMNS = {
"name",
"address",
"wifi_speed_mbps",
"socket_availability",
"noise_level",
"vibe_category",
"price_range",
}
# =============================================================================
# Initialize Recommender
# =============================================================================
def initialize_recommender() -> Optional[WarkopRecommender]:
try:
if not CSV_PATH.exists():
logger.error("CSV file not found: %s", CSV_PATH)
return None
rec = WarkopRecommender(str(CSV_PATH))
logger.info("Loaded %s warkops successfully", len(rec))
return rec
except Exception as e:
logger.exception("Failed to initialize recommender: %s", e)
return None
recommender = initialize_recommender()
def _safe_df() -> pd.DataFrame:
if recommender is None or not hasattr(recommender, "df") or recommender.df is None:
return pd.DataFrame(columns=list(REQUIRED_COLUMNS))
return recommender.df.copy()
BASE_DF = _safe_df()
VIBE_TAGS = (
recommender.get_all_vibe_tags()
if recommender is not None and hasattr(recommender, "get_all_vibe_tags")
else []
)
WARKOP_NAMES = (
sorted(BASE_DF["name"].dropna().astype(str).unique().tolist())
if "name" in BASE_DF.columns
else []
)
ADDRESS_LIST = (
sorted(BASE_DF["address"].dropna().astype(str).unique().tolist())
if "address" in BASE_DF.columns
else []
)
# =============================================================================
# Utility Helpers
# =============================================================================
def _safe_float(value, default: float = 0.0) -> float:
try:
if pd.isna(value):
return default
return float(value)
except (TypeError, ValueError):
return default
def _safe_str(value, default: str = "-") -> str:
if value is None:
return default
try:
if pd.isna(value):
return default
except Exception:
pass
return str(value)
def _status_msg(msg: str, level: str = "info") -> str:
level = escape(level)
return f'<div class="status-bar status-{level}"><span class="status-dot"></span>{msg}</div>'
def _no_recommender_msg() -> str:
detail = escape(str(CSV_PATH))
return _status_msg(
f"Recommender belum berhasil dimuat. Cek file <code>{detail}</code> dan format dataset.",
"error",
)
def _empty_initial_msg(msg: str) -> str:
return f'<div class="empty-state-initial">{escape(msg)}</div>'
def _validate_recommender_ready() -> Optional[Tuple[str, str]]:
if recommender is None:
return _no_recommender_msg(), ""
df = _safe_df()
if df.empty:
return _status_msg("Dataset kosong atau gagal dimuat.", "warning"), ""
missing = REQUIRED_COLUMNS - set(df.columns)
if missing:
return (
_status_msg(
f"Kolom dataset tidak lengkap: {escape(', '.join(sorted(missing)))}",
"error",
),
"",
)
return None
# =============================================================================
# Card Renderer
# =============================================================================
def _render_level_bars(level: str) -> str:
level = _safe_str(level, "Unknown")
val = LEVEL_BARS.get(level, 0)
bars = "".join(
f'<span class="bar {"active" if i < val else ""}"></span>'
for i in range(3)
)
return f'<div class="level-bars" title="{escape(level)}">{bars}</div>'
def _render_wifi_indicator(speed: float) -> str:
speed = _safe_float(speed, 0.0)
if speed >= 50:
cls = "wifi-fast"
elif speed >= 30:
cls = "wifi-medium"
else:
cls = "wifi-slow"
return f'<span class="wifi-badge {cls}">{int(speed)} Mbps</span>'
def _generate_summary(row: pd.Series) -> str:
wifi = _safe_float(row.get("wifi_speed_mbps", 0))
socket = _safe_str(row.get("socket_availability", "Medium"))
noise = _safe_str(row.get("noise_level", "Medium"))
price = _safe_str(row.get("price_range", "Medium"))
vibe = _safe_str(row.get("vibe_category", ""))
points = []
if wifi >= 50:
points.append("WiFi cepat")
elif wifi >= 30:
points.append("WiFi cukup stabil")
if socket == "High":
points.append("colokan banyak")
if noise == "Low":
points.append("suasana tenang")
elif noise == "Medium":
points.append("suasana cukup nyaman")
if price == "Cheap":
points.append("ramah di kantong")
elif price == "Medium":
points.append("harga masih aman")
vibe_lower = vibe.lower()
if "coding" in vibe_lower or "work" in vibe_lower or "study" in vibe_lower:
points.append("cocok buat kerja atau nugas")
elif "social" in vibe_lower or "hangout" in vibe_lower:
points.append("enak buat nongkrong")
if not points:
return "Cocok untuk santai dan menikmati suasana."
return "Cocok untuk " + ", ".join(points[:4]) + "."
def _render_card(row: pd.Series, rank: Optional[int] = None) -> str:
name = escape(_safe_str(row.get("name", "-")))
address = escape(_safe_str(row.get("address", "-")))
vibe_raw = _safe_str(row.get("vibe_category", "-"))
wifi = _safe_float(row.get("wifi_speed_mbps", 0))
socket = _safe_str(row.get("socket_availability", "Medium"))
noise = _safe_str(row.get("noise_level", "Medium"))
price = _safe_str(row.get("price_range", "Medium"))
summary = escape(_generate_summary(row))
score_html = ""
if "match_score" in row.index and pd.notna(row.get("match_score")):
score_html = f'<div class="card-score">Cocok {_safe_float(row.get("match_score")):.0f}<small>%</small></div>'
elif "similarity_score" in row.index and pd.notna(row.get("similarity_score")):
score_html = f'<div class="card-score similarity">Mirip {_safe_float(row.get("similarity_score")):.0f}<small>%</small></div>'
rank_html = f'<div class="card-rank">#{rank}</div>' if rank is not None else ""
vibe_tags = "".join(
f'<span class="vibe-tag">{escape(tag.strip())}</span>'
for tag in vibe_raw.split("/")
if tag.strip()
)
price_key = price if price in PRICE_SYMBOLS else "Medium"
price_class = escape(price.lower().replace(" ", "-"))
return f"""
<div class="warkop-card">
<div class="card-header">
{rank_html}
<div class="card-title-section">
<h3 class="card-title">{name}</h3>
<div class="card-address">{address}</div>
</div>
{score_html}
</div>
<div class="card-summary">{summary}</div>
<div class="card-tags">{vibe_tags or '<span class="vibe-tag">Nyaman</span>'}</div>
<div class="card-stats">
<div class="stat-item">
<span class="stat-icon-wifi"></span>
<div class="stat-content">
<div class="stat-label">Internet</div>
{_render_wifi_indicator(wifi)}
</div>
</div>
<div class="stat-item">
<span class="stat-icon-socket"></span>
<div class="stat-content">
<div class="stat-label">Colokan</div>
{_render_level_bars(socket)}
</div>
</div>
<div class="stat-item">
<span class="stat-icon-noise"></span>
<div class="stat-content">
<div class="stat-label">Suasana</div>
{_render_level_bars(noise)}
</div>
</div>
<div class="stat-item">
<span class="stat-icon-price"></span>
<div class="stat-content">
<div class="stat-label">Harga</div>
<span class="price-badge price-{price_class}">{PRICE_SYMBOLS.get(price_key, "?")}</span>
</div>
</div>
</div>
</div>
"""
def _render_cards(df: Optional[pd.DataFrame], empty_msg: str = "Belum ada hasil") -> str:
if df is None or df.empty:
return f"""
<div class="empty-state">
<div class="empty-icon"></div>
<div class="empty-title">{escape(empty_msg)}</div>
<div class="empty-subtitle">Coba ubah filter atau query Anda</div>
</div>
"""
cards = []
for idx, (_, row) in enumerate(df.iterrows(), start=1):
cards.append(_render_card(row, rank=idx))
return f'<div class="cards-grid">{"".join(cards)}</div>'
# =============================================================================
# Feature Handlers
# =============================================================================
def smart_recommend(wifi, socket, quiet, value, price_filter, vibe_filter, top_n):
validation = _validate_recommender_ready()
if validation:
return validation
try:
filters = {}
if price_filter and price_filter != "Semua":
filters["price_range"] = price_filter
if vibe_filter and vibe_filter != "Semua":
filters["vibe_category"] = vibe_filter
result = recommender.recommend_by_weights(
wifi_imp=float(wifi),
socket_imp=float(socket),
quiet_imp=float(quiet),
value_imp=float(value),
top_n=int(top_n),
filters=filters or None,
)
if result is None or result.empty:
return (
_render_cards(result, "Tidak ada warkop sesuai filter"),
_status_msg("Coba longgarkan filter Anda.", "warning"),
)
return (
_render_cards(result),
_status_msg(f"Berhasil menemukan {len(result)} rekomendasi.", "success"),
)
except Exception as e:
logger.exception("smart_recommend error")
return "", _status_msg(f"Error: {escape(str(e))}", "error")
def use_preset(preset_key: str) -> Tuple[float, float, float, float]:
profile = PRESET_PROFILES.get(preset_key, PRESET_PROFILES["coding"])
weights = profile["weights"]
return (
weights["wifi_imp"],
weights["socket_imp"],
weights["quiet_imp"],
weights["value_imp"],
)
def story_search(query, top_n):
validation = _validate_recommender_ready()
if validation:
return validation
query = _safe_str(query, "").strip()
if not query:
return _empty_initial_msg("Tulis deskripsi pencarian Anda"), _status_msg("Query kosong.", "warning")
try:
result = recommender.recommend_by_story(query, top_n=int(top_n))
if result is None or result.empty:
return _render_cards(result, "Tidak ada hasil relevan"), _status_msg("Tidak ada hasil relevan.", "warning")
short_query = escape(query[:80])
return (
_render_cards(result),
_status_msg(f'{len(result)} warkop ditemukan untuk: "{short_query}"', "success"),
)
except Exception as e:
logger.exception("story_search error")
return "", _status_msg(f"Error: {escape(str(e))}", "error")
def hybrid_search(query, wifi, socket, quiet, value, ratio, top_n):
validation = _validate_recommender_ready()
if validation:
return validation
try:
result = recommender.recommend_hybrid(
user_query=_safe_str(query, ""),
weights={
"wifi_imp": float(wifi),
"socket_imp": float(socket),
"quiet_imp": float(quiet),
"value_imp": float(value),
},
text_ratio=float(ratio),
top_n=int(top_n),
)
if result is None or result.empty:
return _render_cards(result, "Tidak ada hasil"), _status_msg("Tidak ada hasil.", "warning")
return (
_render_cards(result),
_status_msg(f"Hasil pencarian pintar menemukan {len(result)} rekomendasi.", "success"),
)
except Exception as e:
logger.exception("hybrid_search error")
return "", _status_msg(f"Error: {escape(str(e))}", "error")
def vibe_search(vibe_tag, top_n):
validation = _validate_recommender_ready()
if validation:
return validation
vibe_tag = _safe_str(vibe_tag, "").strip()
if not vibe_tag:
return _empty_initial_msg("Pilih kategori vibe"), _status_msg("Pilih vibe dulu.", "warning")
try:
result = recommender.recommend_by_weights(
top_n=int(top_n),
filters={"vibe_category": vibe_tag},
)
if result is None or result.empty:
return (
_render_cards(result, f"Tidak ada warkop dengan vibe '{vibe_tag}'"),
_status_msg(f"Tidak ada warkop dengan vibe '{escape(vibe_tag)}'.", "warning"),
)
return (
_render_cards(result),
_status_msg(f"{len(result)} warkop dengan vibe '{escape(vibe_tag)}'.", "success"),
)
except Exception as e:
logger.exception("vibe_search error")
return "", _status_msg(f"Error: {escape(str(e))}", "error")
def find_similar(warkop_name, top_n):
validation = _validate_recommender_ready()
if validation:
return validation
warkop_name = _safe_str(warkop_name, "").strip()
if not warkop_name:
return _empty_initial_msg("Pilih warkop referensi"), _status_msg("Pilih warkop dulu.", "warning")
try:
result = recommender.find_similar(warkop_name, top_n=int(top_n))
if result is None or result.empty:
return _render_cards(result, "Tidak ditemukan warkop serupa"), _status_msg("Tidak ada hasil serupa.", "warning")
return (
_render_cards(result),
_status_msg(f"Warkop yang mirip dengan '{escape(warkop_name)}'.", "success"),
)
except WarkopRecommenderError as e:
logger.warning("find_similar warning: %s", e)
return "", _status_msg(escape(str(e)), "warning")
except Exception as e:
logger.exception("find_similar error")
return "", _status_msg(f"Error: {escape(str(e))}", "error")
def filter_by_address(address, top_n):
validation = _validate_recommender_ready()
if validation:
return validation
address = _safe_str(address, "").strip()
if not address:
return _empty_initial_msg("Pilih kawasan"), _status_msg("Pilih kawasan dulu.", "warning")
try:
df = _safe_df()
result = df[df["address"].astype(str).str.contains(address, case=False, na=False)]
if result.empty:
return _render_cards(result, f"Tidak ada di '{address}'"), _status_msg(
f"Tidak ada warkop di '{escape(address)}'.",
"warning",
)
result = result.head(int(top_n))
return (
_render_cards(result),
_status_msg(f"{len(result)} warkop di '{escape(address)}'.", "success"),
)
except Exception as e:
logger.exception("filter_by_address error")
return "", _status_msg(f"Error: {escape(str(e))}", "error")
def compare_warkops(warkop_a, warkop_b):
validation = _validate_recommender_ready()
if validation:
return validation[0]
warkop_a = _safe_str(warkop_a, "").strip()
warkop_b = _safe_str(warkop_b, "").strip()
if not warkop_a or not warkop_b:
return _empty_initial_msg("Pilih dua warkop untuk dibandingkan")
if warkop_a == warkop_b:
return _status_msg("Pilih dua warkop yang berbeda.", "warning")
try:
df = _safe_df()
row_a_df = df[df["name"].astype(str) == warkop_a]
row_b_df = df[df["name"].astype(str) == warkop_b]
if row_a_df.empty or row_b_df.empty:
return _status_msg("Salah satu warkop tidak ditemukan di database.", "warning")
row_a = row_a_df.iloc[0]
row_b = row_b_df.iloc[0]
def _compare_row(label, val_a, val_b) -> str:
return (
'<div class="cmp-row">'
f'<div class="cmp-label">{escape(str(label))}</div>'
f'<div class="cmp-val">{escape(_safe_str(val_a))}</div>'
f'<div class="cmp-val">{escape(_safe_str(val_b))}</div>'
"</div>"
)
rows = [
_compare_row("Alamat", row_a.get("address"), row_b.get("address")),
_compare_row("WiFi", f"{_safe_str(row_a.get('wifi_speed_mbps'))} Mbps", f"{_safe_str(row_b.get('wifi_speed_mbps'))} Mbps"),
_compare_row("Colokan", row_a.get("socket_availability"), row_b.get("socket_availability")),
_compare_row("Noise", row_a.get("noise_level"), row_b.get("noise_level")),
_compare_row("Harga", row_a.get("price_range"), row_b.get("price_range")),
_compare_row("Vibe", row_a.get("vibe_category"), row_b.get("vibe_category")),
]
return f"""
<div class="compare-container">
<div class="cmp-header">
<div class="cmp-label-h">Atribut</div>
<div class="cmp-name-h">{escape(warkop_a)}</div>
<div class="cmp-name-h">{escape(warkop_b)}</div>
</div>
<div class="cmp-body">{''.join(rows)}</div>
</div>
"""
except Exception as e:
logger.exception("compare_warkops error")
return _status_msg(f"Error: {escape(str(e))}", "error")
def get_stats():
validation = _validate_recommender_ready()
if validation:
return validation[0]
try:
s = recommender.get_quick_stats()
top_vibes = s.get("Top Vibe Tags", {}) or {}
top_vibes_html = "".join(
f'<div class="vibe-stat-item"><span class="vibe-stat-name">{escape(str(k))}</span><span class="vibe-stat-count">{escape(str(v))}</span></div>'
for k, v in top_vibes.items()
)
return f"""
<div class="stats-hero">
<div class="stats-hero-number">{escape(str(s.get('Total Warkop', 0)))}</div>
<div class="stats-hero-label">Total Warkops Tersedia di Lhokseumawe</div>
</div>
<div class="stats-grid">
<div class="stat-card-v2">
<div class="stat-card-icon stat-blue"></div>
<div class="stat-card-body">
<div class="stat-card-label">WiFi Tercepat</div>
<div class="stat-card-value">{_safe_float(s.get('WiFi Tercepat (Mbps)', 0)):.0f}<small> Mbps</small></div>
</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-icon stat-blue"></div>
<div class="stat-card-body">
<div class="stat-card-label">WiFi Rata-rata</div>
<div class="stat-card-value">{_safe_float(s.get('WiFi Rata-rata (Mbps)', 0)):.1f}<small> Mbps</small></div>
</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-icon stat-green"></div>
<div class="stat-card-body">
<div class="stat-card-label">Spot Tenang</div>
<div class="stat-card-value">{escape(_safe_str(s.get('Spot Paling Tenang', '-')))}</div>
</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-icon stat-amber"></div>
<div class="stat-card-body">
<div class="stat-card-label">Budget Friendly</div>
<div class="stat-card-value">{escape(_safe_str(s.get('Budget Friendly', '-')))}</div>
</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-icon stat-purple"></div>
<div class="stat-card-body">
<div class="stat-card-label">Premium</div>
<div class="stat-card-value">{escape(_safe_str(s.get('Premium', '-')))}</div>
</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-icon stat-teal"></div>
<div class="stat-card-body">
<div class="stat-card-label">Colokan Melimpah</div>
<div class="stat-card-value">{escape(_safe_str(s.get('Socket Melimpah', '-')))}</div>
</div>
</div>
</div>
<div class="vibe-stats-section">
<h3 class="section-title">Top Vibe Categories</h3>
<div class="vibe-stats-grid">{top_vibes_html or '<div class="empty-subtitle">Tidak ada data vibe.</div>'}</div>
</div>
"""
except Exception as e:
logger.exception("get_stats error")
return _status_msg(f"Error: {escape(str(e))}", "error")
def display_all(sort_by, ascending):
validation = _validate_recommender_ready()
if validation:
return validation[0]
try:
df = _safe_df()
if sort_by in df.columns:
if sort_by in {"name", "address", "vibe_category", "price_range", "noise_level", "socket_availability"}:
df[sort_by] = df[sort_by].astype(str)
df = df.sort_values(sort_by, ascending=bool(ascending), na_position="last")
df = df.drop(columns=[c for c in ["metadata"] if c in df.columns], errors="ignore")
return _render_cards(df, "Database kosong")
except Exception as e:
logger.exception("display_all error")
return _status_msg(f"Error: {escape(str(e))}", "error")
# =============================================================================
# Custom CSS - Tema Warkop
# =============================================================================
CUSTOM_CSS = """
:root {
--color-primary: #8B5E3C;
--color-primary-dark: #6F4528;
--color-primary-light: #B07A52;
--color-accent: #D4A574;
--color-accent-dark: #B98552;
--bg-main: #2B1D17;
--bg-soft: #3A281F;
--bg-card: #4A3328;
--bg-card-2: #5A4032;
--bg-hover: #674938;
--border-color: #6B4A37;
--border-light: #7A5440;
--color-text: #F6EBDD;
--color-text-light: #D8C3B0;
--color-muted: #B89B83;
--color-success: #7BA05B;
--color-warning: #D4A574;
--color-error: #C97B63;
--shadow-sm: 0 3px 10px rgba(0, 0, 0, 0.18);
--shadow-md: 0 8px 20px rgba(0, 0, 0, 0.22);
--shadow-lg: 0 14px 32px rgba(0, 0, 0, 0.26);
--radius-sm: 12px;
--radius-md: 16px;
--radius-lg: 22px;
}
html, body, .gradio-container {
background:
radial-gradient(circle at top left, rgba(212,165,116,0.08), transparent 20%),
radial-gradient(circle at bottom right, rgba(139,94,60,0.14), transparent 25%),
linear-gradient(180deg, #241813 0%, #2B1D17 100%) !important;
color: var(--color-text) !important;
}
.gradio-container {
max-width: 1380px !important;
margin: auto;
font-family: 'Inter', 'Segoe UI', sans-serif !important;
padding-bottom: 20px;
}
* { box-sizing: border-box; }
label, p, span, div { color: inherit; }
/* HEADER */
.app-header {
background:
radial-gradient(circle at top right, rgba(212,165,116,0.16), transparent 24%),
linear-gradient(135deg, #3A281F 0%, #4A3328 100%);
color: white;
padding: 34px 30px;
border-radius: var(--radius-lg);
margin-bottom: 24px;
position: relative;
overflow: hidden;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
}
.app-header::before {
content: '';
position: absolute;
top: -90px;
right: -80px;
width: 320px;
height: 320px;
background: radial-gradient(circle, rgba(255, 220, 180, 0.07) 0%, transparent 70%);
opacity: 1;
}
.app-header::after {
content: '';
position: absolute;
bottom: -60px;
left: -40px;
width: 240px;
height: 240px;
background: radial-gradient(circle, rgba(139,94,60,0.18) 0%, transparent 70%);
opacity: 1;
}
.app-header-content { position: relative; z-index: 1; }
.app-brand {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
.app-logo {
width: 60px;
height: 60px;
background: linear-gradient(135deg, #E6C29A 0%, #D4A574 100%);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 900;
color: #4A2F22;
box-shadow: 0 8px 20px rgba(212, 165, 116, 0.24);
}
.app-title {
font-size: 2em;
font-weight: 800;
margin: 0;
letter-spacing: -0.5px;
line-height: 1.15;
color: #FFF5EA;
}
.app-subtitle {
font-size: 0.98em;
margin-top: 4px;
max-width: 760px;
line-height: 1.6;
color: #EADACA;
}
.app-meta {
display: flex;
gap: 14px;
margin-top: 16px;
flex-wrap: wrap;
}
.app-meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.88em;
background: rgba(255,255,255,0.06);
color: #F6EBDD;
padding: 8px 13px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(4px);
}
.app-meta-dot {
width: 7px;
height: 7px;
background: var(--color-accent);
border-radius: 50%;
display: inline-block;
}
/* TABS */
button[role="tab"] {
color: var(--color-text-light) !important;
background: transparent !important;
border-radius: 10px !important;
border: none !important;
font-weight: 700 !important;
}
button[role="tab"]:hover {
background: rgba(212,165,116,0.08) !important;
color: #FFF5EA !important;
}
button[role="tab"][aria-selected="true"] {
background: linear-gradient(135deg, #8B5E3C 0%, #B07A52 100%) !important;
color: white !important;
box-shadow: 0 4px 14px rgba(139,94,60,0.24) !important;
}
/* TITLES */
.panel-title {
font-size: 1.08em;
font-weight: 800;
color: #FFF5EA;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.panel-subtitle {
font-size: 0.92em;
color: var(--color-text-light);
margin: -8px 0 16px 0;
line-height: 1.6;
}
/* BOXES */
.helper-box,
.soft-note,
.empty-state,
.empty-state-initial,
.compare-container,
.about-section,
.stat-card-v2,
.vibe-stats-section,
.warkop-card {
background: var(--bg-card) !important;
color: var(--color-text);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
}
.helper-box {
border-radius: var(--radius-md);
padding: 14px 16px;
margin: 0 0 16px 0;
background: linear-gradient(180deg, #52392C 0%, #473126 100%) !important;
}
.helper-title {
font-size: 0.82em;
font-weight: 800;
color: #F2D0A7;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.helper-step {
font-size: 0.92em;
color: var(--color-text);
margin: 7px 0;
line-height: 1.5;
}
.soft-note {
border-left: 4px solid var(--color-accent);
padding: 12px 14px;
border-radius: 10px;
font-size: 0.9em;
margin-bottom: 14px;
line-height: 1.55;
background: linear-gradient(180deg, #4A3328 0%, #422D23 100%) !important;
}
/* INPUTS */
input, textarea, select {
background: var(--bg-soft) !important;
color: var(--color-text) !important;
border: 1px solid var(--border-color) !important;
border-radius: 12px !important;
}
textarea::placeholder,
input::placeholder {
color: var(--color-muted) !important;
}
input:focus, textarea:focus, select:focus {
border-color: var(--color-accent) !important;
box-shadow: 0 0 0 1px rgba(212,165,116,0.2) !important;
}
/* PRESET */
.preset-grid { gap: 8px !important; flex-wrap: wrap !important; }
.preset-grid button {
flex: 1 !important;
min-width: 110px !important;
background: var(--bg-card-2) !important;
border: 1px solid var(--border-color) !important;
color: var(--color-text) !important;
padding: 12px 10px !important;
font-size: 0.86em !important;
font-weight: 700 !important;
border-radius: var(--radius-sm) !important;
transition: all 0.2s ease !important;
cursor: pointer !important;
white-space: pre-line !important;
}
.preset-grid button:hover {
border-color: var(--color-accent) !important;
background: var(--bg-hover) !important;
color: #FFF5EA !important;
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
/* STATUS */
.status-bar {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: var(--radius-sm);
font-size: 0.92em;
font-weight: 600;
margin: 12px 0;
animation: slideIn 0.3s ease;
border: 1px solid transparent;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-info {
background: rgba(176,122,82,0.14);
color: #F2D0A7;
border-color: rgba(176,122,82,0.18);
}
.status-info .status-dot { background: #D4A574; }
.status-success {
background: rgba(123,160,91,0.14);
color: #D8F0C2;
border-color: rgba(123,160,91,0.18);
}
.status-success .status-dot { background: #7BA05B; }
.status-warning {
background: rgba(212,165,116,0.14);
color: #F5D9B5;
border-color: rgba(212,165,116,0.18);
}
.status-warning .status-dot { background: #D4A574; }
.status-error {
background: rgba(201,123,99,0.14);
color: #F3C6B7;
border-color: rgba(201,123,99,0.18);
}
.status-error .status-dot { background: #C97B63; }
/* CARDS */
.cards-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
animation: fadeIn 0.35s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.warkop-card {
border-radius: var(--radius-md);
padding: 18px 20px;
transition: all 0.25s ease;
position: relative;
background: linear-gradient(180deg, #4A3328 0%, #412D23 100%) !important;
}
.warkop-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
border-color: var(--color-accent);
}
.card-header {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 8px;
}
.card-rank {
background: linear-gradient(135deg, #B07A52 0%, #8B5E3C 100%);
color: white;
min-width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 0.92em;
flex-shrink: 0;
}
.card-title-section { flex: 1; min-width: 0; }
.card-title {
margin: 0 0 5px 0;
font-size: 1.1em;
font-weight: 800;
color: #FFF5EA;
line-height: 1.3;
}
.card-address {
font-size: 0.86em;
color: var(--color-text-light);
display: flex;
align-items: center;
gap: 4px;
}
.card-address::before {
content: '◉';
color: var(--color-accent);
}
.card-summary {
font-size: 0.9em;
color: #EADACA;
margin: 0 0 12px 0;
line-height: 1.6;
}
.card-score {
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-dark) 100%);
color: #3A281F;
padding: 7px 12px;
border-radius: 12px;
font-weight: 800;
font-size: 0.96em;
flex-shrink: 0;
text-align: center;
}
.card-score small {
font-size: 0.72em;
font-weight: 700;
margin-left: 1px;
}
.card-score.similarity {
background: linear-gradient(135deg, #A67C52 0%, #8B5E3C 100%);
color: white;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.vibe-tag {
background: #3A281F;
color: #F2D0A7;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78em;
font-weight: 700;
border: 1px solid var(--border-color);
}
.card-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
}
.stat-content { flex: 1; min-width: 0; }
.stat-label {
font-size: 0.72em;
color: var(--color-text-light);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 700;
margin-bottom: 2px;
}
.stat-icon-wifi, .stat-icon-socket, .stat-icon-noise, .stat-icon-price {
width: 22px;
height: 22px;
border-radius: 7px;
flex-shrink: 0;
position: relative;
background: #3A281F;
border: 1px solid var(--border-color);
}
.stat-icon-wifi::before {
content: '';
position: absolute;
inset: 5px;
border: 2px solid var(--color-accent);
border-bottom: none;
border-radius: 50% 50% 0 0;
}
.stat-icon-socket::before {
content: '';
position: absolute;
inset: 6px;
background: var(--color-accent);
border-radius: 2px;
}
.stat-icon-noise::before {
content: '';
position: absolute;
inset: 5px;
border-left: 2px solid var(--color-accent);
}
.stat-icon-noise::after {
content: '';
position: absolute;
top: 5px;
left: 9px;
width: 6px;
height: 10px;
border: 2px solid var(--color-accent);
border-left: none;
border-radius: 0 6px 6px 0;
}
.stat-icon-price::before {
content: '$';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
color: var(--color-accent);
font-size: 0.9em;
}
.wifi-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 7px;
font-size: 0.78em;
font-weight: 800;
}
.wifi-fast { background: rgba(123,160,91,0.16); color: #D8F0C2; }
.wifi-medium { background: rgba(212,165,116,0.16); color: #F5D9B5; }
.wifi-slow { background: rgba(201,123,99,0.16); color: #F3C6B7; }
.level-bars {
display: flex;
gap: 3px;
align-items: flex-end;
}
.level-bars .bar {
width: 6px;
background: #7A5A45;
border-radius: 2px;
}
.level-bars .bar:nth-child(1) { height: 6px; }
.level-bars .bar:nth-child(2) { height: 10px; }
.level-bars .bar:nth-child(3) { height: 14px; }
.level-bars .bar.active { background: var(--color-accent); }
.price-badge {
font-weight: 900;
font-size: 0.95em;
letter-spacing: -1px;
}
.price-cheap { color: #D8F0C2; }
.price-medium { color: #F5D9B5; }
.price-expensive { color: #F3C6B7; }
/* EMPTY */
.empty-state,
.empty-state-initial {
text-align: center;
padding: 50px 20px;
border-radius: var(--radius-md);
background: linear-gradient(180deg, #4A3328 0%, #412D23 100%) !important;
}
.empty-state-initial {
color: var(--color-text-light);
font-size: 0.95em;
}
.empty-icon {
width: 60px;
height: 60px;
background: #3A281F;
border: 1px solid var(--border-color);
border-radius: 50%;
margin: 0 auto 16px;
position: relative;
}
.empty-icon::before {
content: '☕';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
}
.empty-title {
font-size: 1.1em;
font-weight: 800;
color: #FFF5EA;
margin-bottom: 6px;
}
.empty-subtitle {
font-size: 0.9em;
color: var(--color-text-light);
}
/* STATS */
.stats-hero {
background:
radial-gradient(circle at top right, rgba(212,165,116,0.14), transparent 30%),
linear-gradient(135deg, #3A281F 0%, #52392C 100%);
color: white;
padding: 30px;
border-radius: var(--radius-md);
text-align: center;
margin-bottom: 20px;
position: relative;
overflow: hidden;
border: 1px solid var(--border-light);
}
.stats-hero-number {
font-size: 3.8em;
font-weight: 900;
line-height: 1;
color: #F2D0A7;
position: relative;
}
.stats-hero-label {
font-size: 1em;
opacity: 0.95;
margin-top: 8px;
position: relative;
color: #EADACA;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.stat-card-v2 {
padding: 18px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: 14px;
transition: all 0.2s ease;
background: linear-gradient(180deg, #4A3328 0%, #412D23 100%) !important;
}
.stat-card-v2:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.stat-card-icon {
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
}
.stat-blue { background: linear-gradient(135deg, #B07A52 0%, #8B5E3C 100%); }
.stat-green { background: linear-gradient(135deg, #7BA05B 0%, #5E7E47 100%); }
.stat-amber { background: linear-gradient(135deg, #D4A574 0%, #B98552 100%); }
.stat-purple { background: linear-gradient(135deg, #9C7A63 0%, #7B5A46 100%); }
.stat-teal { background: linear-gradient(135deg, #7D9D8C 0%, #5F7C6E 100%); }
.stat-card-body { flex: 1; min-width: 0; }
.stat-card-label {
font-size: 0.78em;
color: var(--color-text-light);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-card-value {
font-size: 1.6em;
font-weight: 800;
color: #FFF5EA;
line-height: 1.1;
}
.stat-card-value small {
font-size: 0.52em;
color: var(--color-text-light);
font-weight: 700;
}
.section-title {
font-size: 1.12em;
font-weight: 800;
color: #FFF5EA;
margin: 24px 0 16px 0;
}
.vibe-stats-section {
padding: 20px;
border-radius: var(--radius-md);
}
.vibe-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 8px;
}
.vibe-stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: #3A281F;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
}
.vibe-stat-name {
font-weight: 700;
color: var(--color-text);
font-size: 0.9em;
}
.vibe-stat-count {
background: linear-gradient(135deg, #B07A52 0%, #8B5E3C 100%);
color: white;
padding: 2px 10px;
border-radius: 12px;
font-weight: 800;
font-size: 0.85em;
}
/* COMPARE */
.compare-container {
border-radius: var(--radius-md);
overflow-x: auto;
}
.cmp-header {
display: grid;
grid-template-columns: 1fr 1.5fr 1.5fr;
background: linear-gradient(135deg, #8B5E3C 0%, #B07A52 100%);
color: white;
padding: 14px 18px;
font-weight: 800;
gap: 16px;
min-width: 680px;
}
.cmp-name-h { font-size: 1em; }
.cmp-body { padding: 8px 0; min-width: 680px; }
.cmp-row {
display: grid;
grid-template-columns: 1fr 1.5fr 1.5fr;
padding: 12px 18px;
gap: 16px;
border-bottom: 1px solid var(--border-color);
align-items: center;
}
.cmp-row:last-child { border-bottom: none; }
.cmp-row:nth-child(even) { background: rgba(255,255,255,0.02); }
.cmp-label {
font-weight: 800;
color: #F2D0A7;
font-size: 0.88em;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cmp-val {
color: var(--color-text);
font-weight: 500;
}
/* BUTTON */
button.primary,
.action-btn button.primary,
button.lg.primary {
background: linear-gradient(135deg, #8B5E3C 0%, #B07A52 100%) !important;
border: none !important;
color: white !important;
font-weight: 800 !important;
padding: 12px 24px !important;
border-radius: var(--radius-sm) !important;
transition: all 0.2s ease !important;
box-shadow: 0 4px 14px rgba(139, 94, 60, 0.24) !important;
}
button.primary:hover,
.action-btn button.primary:hover,
button.lg.primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(139, 94, 60, 0.32) !important;
}
/* FOOTER */
.app-footer {
text-align: center;
padding: 26px 20px;
color: var(--color-text-light);
font-size: 0.9em;
border: 1px solid var(--border-color);
margin-top: 34px;
background: var(--bg-card);
border-radius: var(--radius-md);
}
.app-footer strong { color: #FFF5EA; }
.app-footer .footer-divider {
display: inline-block;
margin: 0 10px;
color: var(--color-muted);
}
/* ABOUT */
.about-section {
border-radius: var(--radius-md);
padding: 24px;
margin-bottom: 16px;
background: linear-gradient(180deg, #4A3328 0%, #412D23 100%) !important;
}
.about-section h3 {
font-size: 1.08em;
font-weight: 800;
color: #FFF5EA;
margin: 0 0 16px 0;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
.about-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
.about-item {
display: flex;
gap: 12px;
padding: 14px;
background: #3A281F;
border-radius: var(--radius-sm);
align-items: flex-start;
border: 1px solid var(--border-color);
}
.about-item-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, #B07A52 0%, #8B5E3C 100%);
flex-shrink: 0;
}
.about-item-content .about-item-title {
font-weight: 800;
font-size: 0.92em;
color: #FFF5EA;
margin-bottom: 3px;
}
.about-item-content .about-item-desc {
font-size: 0.84em;
color: var(--color-text-light);
line-height: 1.5;
}
.algo-box {
background: #241813;
color: #F2D0A7;
padding: 16px 20px;
border-radius: var(--radius-sm);
font-family: 'Courier New', monospace;
font-size: 0.85em;
line-height: 1.7;
margin: 12px 0;
overflow-x: auto;
border: 1px solid var(--border-color);
}
.algo-box .algo-comment { color: #B89B83; }
.algo-box .algo-var { color: #E6C29A; }
/* SCROLLBAR */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #3A281F; }
::-webkit-scrollbar-thumb { background: #7A5440; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #8B5E3C; }
/* RESPONSIVE */
@media (max-width: 900px) {
.app-title { font-size: 1.55em; }
.card-stats { grid-template-columns: repeat(2, 1fr); }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.app-header { padding: 24px 18px; }
.app-logo { width: 46px; height: 46px; font-size: 22px; }
.app-title { font-size: 1.3em; }
.app-subtitle { font-size: 0.9em; }
.card-stats { grid-template-columns: repeat(2, 1fr); }
.stats-hero-number { font-size: 3em; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.vibe-stats-grid { grid-template-columns: 1fr; }
}
"""
# =============================================================================
# About Content Helper
# =============================================================================
def _render_about() -> str:
features = [
("Cari Warkop", "Temukan saran warkop berdasarkan kebutuhan seperti WiFi, harga, colokan, dan suasana."),
("Cerita Kebutuhanmu", "Tulis kebutuhanmu dengan bahasa santai, lalu sistem akan mencocokkan hasilnya."),
("Cari yang Lebih Pas", "Gabungkan deskripsi bebas dan preferensi agar hasilnya lebih personal."),
("Pilih Suasana", "Temukan tempat berdasarkan vibe atau suasana yang kamu inginkan."),
("Mirip Warkop Favorit", "Pilih satu warkop favorit, lalu cari alternatif yang mirip."),
("Cari per Kawasan", "Jelajahi warkop berdasarkan area atau alamat tertentu."),
("Bandingkan Tempat", "Bandingkan dua tempat agar lebih mudah memilih."),
("Info Warkop", "Lihat ringkasan statistik seluruh data warkop."),
("Daftar Warkop", "Tampilkan semua warkop dan urutkan sesuai kebutuhan."),
]
items_html = "".join(
f"""
<div class="about-item">
<div class="about-item-icon"></div>
<div class="about-item-content">
<div class="about-item-title">{escape(title)}</div>
<div class="about-item-desc">{escape(desc)}</div>
</div>
</div>
"""
for title, desc in features
)
tech_rows = [
("TF-IDF Vectorizer", "Mengubah teks menjadi representasi numerik agar bisa dibandingkan."),
("MinMax Scaler", "Menormalkan fitur numerik ke rentang yang konsisten."),
("Cosine Similarity", "Mengukur kemiripan antar teks atau item."),
("Content-Based Filtering", "Memberi rekomendasi berdasarkan karakteristik tempat."),
("Caching", "Membantu mempercepat pemrosesan query berulang."),
("Gradio Blocks", "Membangun antarmuka aplikasi interaktif."),
]
tech_html = "".join(
f"""
<div class="cmp-row">
<div class="cmp-label">{escape(tech)}</div>
<div class="cmp-val" style="grid-column: span 2;">{escape(func)}</div>
</div>
"""
for tech, func in tech_rows
)
total = len(_safe_df())
return f"""
<div class="about-section">
<h3>Tentang Aplikasi</h3>
<p style="color:var(--color-text-light); font-size:0.95em; line-height:1.7; margin:0 0 16px 0;">
<strong>{escape(APP_TITLE)}</strong> adalah sistem rekomendasi cerdas
untuk membantu pengguna menemukan warkop terbaik di Lhokseumawe
sesuai kebutuhan pribadi, baik untuk nugas, nongkrong, meeting, maupun santai.
</p>
<div style="display:flex; gap:20px; flex-wrap:wrap; margin-bottom:8px;">
<div style="background:var(--bg-soft); padding:12px 20px; border-radius:var(--radius-sm); text-align:center; border:1px solid var(--border-color);">
<div style="font-size:1.8em; font-weight:800; color:var(--color-accent);">{total}</div>
<div style="font-size:0.8em; color:var(--color-text-light);">Total Warkops</div>
</div>
<div style="background:var(--bg-soft); padding:12px 20px; border-radius:var(--radius-sm); text-align:center; border:1px solid var(--border-color);">
<div style="font-size:1.8em; font-weight:800; color:var(--color-accent);">v4.2</div>
<div style="font-size:0.8em; color:var(--color-text-light);">Versi</div>
</div>
<div style="background:var(--bg-soft); padding:12px 20px; border-radius:var(--radius-sm); text-align:center; border:1px solid var(--border-color);">
<div style="font-size:1.8em; font-weight:800; color:var(--color-accent);">9+</div>
<div style="font-size:0.8em; color:var(--color-text-light);">Fitur</div>
</div>
</div>
</div>
<div class="about-section">
<h3>Fitur Utama</h3>
<div class="about-grid">{items_html}</div>
</div>
<div class="about-section">
<h3>Algoritma</h3>
<div class="algo-box">
<span class="algo-comment"># Numeric Score</span>
<span class="algo-var">score</span> = w_wifi * wifi_normalized
+ w_socket * socket_normalized
+ w_quiet * (1 - noise_normalized)
+ w_value * (1 - price_normalized)
<span class="algo-comment"># Hybrid Score</span>
<span class="algo-var">final</span> = text_ratio * cosine_sim(query, metadata)
+ (1 - text_ratio) * numeric_score
<span class="algo-comment"># Similarity</span>
<span class="algo-var">sim</span> = (cosine_sim_numeric + cosine_sim_text) / 2
</div>
</div>
<div class="about-section">
<h3>Stack Teknologi</h3>
<div class="compare-container">
<div class="cmp-header">
<div class="cmp-label-h">Teknologi</div>
<div class="cmp-name-h" style="grid-column: span 2;">Fungsi</div>
</div>
<div class="cmp-body">{tech_html}</div>
</div>
</div>
<div class="about-section">
<h3>Schema Data</h3>
<div class="compare-container">
<div class="cmp-header">
<div class="cmp-label-h">Kolom</div>
<div class="cmp-name-h">Tipe</div>
<div class="cmp-name-h">Nilai Valid</div>
</div>
<div class="cmp-body">
<div class="cmp-row"><div class="cmp-label">name</div><div class="cmp-val">string</div><div class="cmp-val">Nama warkop</div></div>
<div class="cmp-row"><div class="cmp-label">address</div><div class="cmp-val">string</div><div class="cmp-val">Alamat / kawasan</div></div>
<div class="cmp-row"><div class="cmp-label">wifi_speed_mbps</div><div class="cmp-val">number</div><div class="cmp-val">0 - 100+</div></div>
<div class="cmp-row"><div class="cmp-label">socket_availability</div><div class="cmp-val">enum</div><div class="cmp-val">Low / Medium / High</div></div>
<div class="cmp-row"><div class="cmp-label">noise_level</div><div class="cmp-val">enum</div><div class="cmp-val">Low / Medium / High</div></div>
<div class="cmp-row"><div class="cmp-label">vibe_category</div><div class="cmp-val">string</div><div class="cmp-val">Tag slash-separated</div></div>
<div class="cmp-row"><div class="cmp-label">price_range</div><div class="cmp-val">enum</div><div class="cmp-val">Cheap / Medium / Expensive</div></div>
</div>
</div>
</div>
"""
# =============================================================================
# Build App
# =============================================================================
def build_app() -> gr.Blocks:
with gr.Blocks(
title=APP_TITLE,
theme=gr.themes.Base(),
css=CUSTOM_CSS,
) as app:
gr.HTML(
f"""
<div class="app-header">
<div class="app-header-content">
<div class="app-brand">
<div class="app-logo">☕</div>
<div>
<div class="app-title">Cari Warkop yang Paling Pas Buat Kamu</div>
<div class="app-subtitle">
Temukan tempat nyaman untuk ngopi, nugas, nongkrong, meeting, atau santai di Lhokseumawe.
</div>
</div>
</div>
<div class="app-meta">
<div class="app-meta-item">
<span class="app-meta-dot"></span>
{len(_safe_df())} warkop tersedia
</div>
<div class="app-meta-item">
<span class="app-meta-dot"></span>
Suasana hangat & nyaman
</div>
<div class="app-meta-item">
<span class="app-meta-dot"></span>
Cocok untuk mahasiswa, pekerja, dan nongkrong
</div>
</div>
</div>
</div>
"""
)
if recommender is None:
gr.HTML(_no_recommender_msg())
with gr.Tabs():
with gr.Tab("Cari Warkop"):
with gr.Row(equal_height=False):
with gr.Column(scale=2, min_width=320):
gr.HTML('<div class="panel-title">Atur Kebutuhan Kamu</div>')
gr.HTML('<div class="panel-subtitle">Pilih yang paling penting buat kamu, lalu kami bantu carikan warkop yang cocok.</div>')
gr.HTML("""
<div class="helper-box">
<div class="helper-title">Cara pakai</div>
<div class="helper-step">1. Pilih preset cepat atau atur slider sendiri</div>
<div class="helper-step">2. Tambahkan filter harga atau suasana kalau perlu</div>
<div class="helper-step">3. Klik <b>Cari Sekarang</b></div>
</div>
""")
gr.HTML("<b style='font-size:0.88em;color:var(--color-text-light);'>PRESET CEPAT</b>")
with gr.Row(elem_classes="preset-grid"):
preset_buttons = {}
for key, prof in PRESET_PROFILES.items():
preset_buttons[key] = gr.Button(
f"{prof['label']}\n{prof['subtitle']}",
size="sm",
)
gr.HTML("<hr style='border:1px solid var(--border-color); margin:16px 0;'>")
gr.HTML("<b style='font-size:0.88em;color:var(--color-text-light);'>ATUR MANUAL</b>")
wifi_s = gr.Slider(0, 1, value=0.45, step=0.05, label="Internet cepat")
socket_s = gr.Slider(0, 1, value=0.30, step=0.05, label="Colokan tersedia")
quiet_s = gr.Slider(0, 1, value=0.20, step=0.05, label="Suasana tenang")
value_s = gr.Slider(0, 1, value=0.05, step=0.05, label="Harga terjangkau")
gr.HTML("<hr style='border:1px solid var(--border-color); margin:16px 0;'>")
gr.HTML("<b style='font-size:0.88em;color:var(--color-text-light);'>FILTER TAMBAHAN</b>")
with gr.Row():
price_f = gr.Dropdown(
choices=["Semua"] + PRICE_RANGES,
value="Semua",
label="Rentang harga",
)
vibe_f = gr.Dropdown(
choices=["Semua"] + VIBE_TAGS,
value="Semua",
label="Suasana",
)
top_n_s = gr.Slider(3, 23, value=7, step=1, label="Jumlah hasil")
rec_btn = gr.Button("Cari Sekarang", variant="primary", size="lg")
with gr.Column(scale=3, min_width=420):
gr.HTML('<div class="panel-title">Hasil Rekomendasi</div>')
smart_status = gr.HTML(_empty_initial_msg("Atur preferensimu lalu klik tombol cari."))
smart_out = gr.HTML()
for key, btn in preset_buttons.items():
btn.click(
fn=lambda k=key: use_preset(k),
inputs=[],
outputs=[wifi_s, socket_s, quiet_s, value_s],
)
rec_btn.click(
fn=smart_recommend,
inputs=[wifi_s, socket_s, quiet_s, value_s, price_f, vibe_f, top_n_s],
outputs=[smart_out, smart_status],
show_progress="minimal",
)
with gr.Tab("Cerita Kebutuhanmu"):
with gr.Row(equal_height=False):
with gr.Column(scale=2, min_width=320):
gr.HTML('<div class="panel-title">Ceritakan Warkop yang Kamu Cari</div>')
gr.HTML("""
<div class="panel-subtitle">
Tulis dengan bahasa santai, seperti sedang cerita ke teman.
</div>
""")
gr.HTML("""
<div class="soft-note">
Contoh: "Saya cari tempat tenang buat kerja, internet cepat, dan colokan banyak."
</div>
""")
story_in = gr.Textbox(
label="Deskripsi pencarian",
placeholder="Contoh: saya cari tempat yang tenang, internet cepat, cocok buat nugas sampai malam...",
lines=5,
)
story_n = gr.Slider(3, 10, value=5, step=1, label="Jumlah hasil")
story_btn = gr.Button("Cari dari Cerita", variant="primary", size="lg")
gr.HTML("<hr style='border:1px solid var(--border-color); margin:16px 0;'>")
gr.HTML("<b style='font-size:0.88em;color:var(--color-text-light);'>CONTOH PENCARIAN</b>")
example_queries = [
"warkop tenang wifi cepat buat coding",
"tempat nongkrong murah kumpul teman",
"coffee shop modern yang nyaman",
"warkop buat begadang ngerjain tugas",
"tempat santai yang colokannya banyak",
"warkop premium buat meeting",
]
with gr.Column(elem_classes="preset-grid"):
for ex in example_queries:
ex_btn = gr.Button(ex, size="sm")
ex_btn.click(fn=lambda x=ex: x, inputs=[], outputs=story_in)
with gr.Column(scale=3, min_width=420):
gr.HTML('<div class="panel-title">Hasil Pencarian</div>')
story_status = gr.HTML(_empty_initial_msg("Tulis kebutuhanmu, lalu klik tombol cari."))
story_out = gr.HTML()
story_btn.click(
fn=story_search,
inputs=[story_in, story_n],
outputs=[story_out, story_status],
show_progress="minimal",
)
story_in.submit(
fn=story_search,
inputs=[story_in, story_n],
outputs=[story_out, story_status],
show_progress="minimal",
)
with gr.Tab("Cari yang Lebih Pas"):
with gr.Row(equal_height=False):
with gr.Column(scale=2, min_width=320):
gr.HTML('<div class="panel-title">Gabungkan Cerita dan Preferensi</div>')
gr.HTML("""
<div class="panel-subtitle">
Cocok kalau kamu ingin hasil yang lebih personal: deskripsi + prioritas.
</div>
""")
gr.HTML("""
<div class="helper-box">
<div class="helper-title">Cocok dipakai kalau</div>
<div class="helper-step">• Kamu punya gambaran tempat yang diinginkan</div>
<div class="helper-step">• Kamu juga ingin mengatur prioritas seperti WiFi, harga, atau suasana</div>
</div>
""")
h_query = gr.Textbox(
label="Deskripsi (opsional)",
placeholder="Contoh: warkop modern, tenang, cocok buat kerja di area Pase",
lines=4,
)
gr.HTML("<b style='font-size:0.88em;color:var(--color-text-light);'>PRIORITAS KAMU</b>")
with gr.Row():
h_wifi = gr.Slider(0, 1, value=0.35, step=0.05, label="Internet")
h_socket = gr.Slider(0, 1, value=0.25, step=0.05, label="Colokan")
with gr.Row():
h_quiet = gr.Slider(0, 1, value=0.20, step=0.05, label="Ketenangan")
h_value = gr.Slider(0, 1, value=0.20, step=0.05, label="Harga")
gr.HTML("<hr style='border:1px solid var(--border-color); margin:16px 0;'>")
ratio_s = gr.Slider(
0,
1,
value=0.5,
step=0.05,
label="Fokus Pencarian",
info="Geser ke kiri untuk fokus preferensi, ke kanan untuk fokus deskripsi",
)
h_top = gr.Slider(3, 15, value=7, step=1, label="Jumlah hasil")
h_btn = gr.Button("Cari yang Lebih Pas", variant="primary", size="lg")
with gr.Column(scale=3, min_width=420):
gr.HTML('<div class="panel-title">Hasil Pencarian</div>')
h_status = gr.HTML(_empty_initial_msg("Isi kebutuhanmu lalu klik tombol cari."))
h_out = gr.HTML()
h_btn.click(
fn=hybrid_search,
inputs=[h_query, h_wifi, h_socket, h_quiet, h_value, ratio_s, h_top],
outputs=[h_out, h_status],
show_progress="minimal",
)
with gr.Tab("Pilih Suasana"):
with gr.Row(equal_height=False):
with gr.Column(scale=2, min_width=320):
gr.HTML('<div class="panel-title">Pilih Suasana yang Kamu Mau</div>')
gr.HTML("""
<div class="panel-subtitle">
Cocok kalau kamu sudah tahu vibe tempat yang diinginkan.
</div>
""")
vibe_dd = gr.Dropdown(choices=VIBE_TAGS, label="Pilih suasana")
vibe_n = gr.Slider(3, 20, value=8, step=1, label="Jumlah hasil")
vibe_btn = gr.Button("Tampilkan Hasil", variant="primary", size="lg")
with gr.Column(scale=3, min_width=420):
gr.HTML('<div class="panel-title">Hasil</div>')
vibe_status = gr.HTML(_empty_initial_msg("Pilih suasana lalu klik tombol tampilkan."))
vibe_out = gr.HTML()
vibe_btn.click(
fn=vibe_search,
inputs=[vibe_dd, vibe_n],
outputs=[vibe_out, vibe_status],
show_progress="minimal",
)
with gr.Tab("Mirip Warkop Favorit"):
with gr.Row(equal_height=False):
with gr.Column(scale=2, min_width=320):
gr.HTML('<div class="panel-title">Cari Warkop yang Mirip</div>')
gr.HTML("""
<div class="panel-subtitle">
Sudah punya warkop favorit? Pilih di bawah, lalu kami carikan yang mirip.
</div>
""")
sim_dd = gr.Dropdown(choices=WARKOP_NAMES, label="Pilih warkop referensi")
sim_n = gr.Slider(3, 10, value=5, step=1, label="Jumlah hasil")
sim_btn = gr.Button("Cari yang Mirip", variant="primary", size="lg")
with gr.Column(scale=3, min_width=420):
gr.HTML('<div class="panel-title">Warkop Serupa</div>')
sim_status = gr.HTML(_empty_initial_msg("Pilih satu warkop referensi terlebih dahulu."))
sim_out = gr.HTML()
sim_btn.click(
fn=find_similar,
inputs=[sim_dd, sim_n],
outputs=[sim_out, sim_status],
show_progress="minimal",
)
with gr.Tab("Cari per Kawasan"):
with gr.Row(equal_height=False):
with gr.Column(scale=2, min_width=320):
gr.HTML('<div class="panel-title">Cari Berdasarkan Lokasi</div>')
gr.HTML("""
<div class="panel-subtitle">
Lihat warkop berdasarkan kawasan atau alamat tertentu.
</div>
""")
addr_dd = gr.Dropdown(choices=ADDRESS_LIST, label="Pilih kawasan")
addr_n = gr.Slider(3, 20, value=10, step=1, label="Jumlah hasil")
addr_btn = gr.Button("Cari Lokasi", variant="primary", size="lg")
with gr.Column(scale=3, min_width=420):
gr.HTML('<div class="panel-title">Hasil</div>')
addr_status = gr.HTML(_empty_initial_msg("Pilih kawasan yang ingin kamu lihat."))
addr_out = gr.HTML()
addr_btn.click(
fn=filter_by_address,
inputs=[addr_dd, addr_n],
outputs=[addr_out, addr_status],
show_progress="minimal",
)
with gr.Tab("Bandingkan Tempat"):
gr.HTML('<div class="panel-title">Bandingkan Dua Warkop</div>')
gr.HTML("""
<div class="panel-subtitle">
Cocok untuk membantu memilih tempat sebelum berangkat.
</div>
""")
with gr.Row():
cmp_a = gr.Dropdown(choices=WARKOP_NAMES, label="Warkop pertama")
cmp_b = gr.Dropdown(choices=WARKOP_NAMES, label="Warkop kedua")
cmp_btn = gr.Button("Bandingkan Sekarang", variant="primary", size="lg")
cmp_out = gr.HTML(_empty_initial_msg("Pilih dua warkop yang ingin dibandingkan."))
cmp_btn.click(
fn=compare_warkops,
inputs=[cmp_a, cmp_b],
outputs=cmp_out,
)
with gr.Tab("Info Warkop"):
gr.HTML('<div class="panel-title">Ringkasan Data Warkop</div>')
gr.HTML("""
<div class="panel-subtitle">
Lihat gambaran umum seluruh data warkop yang tersedia.
</div>
""")
stats_btn = gr.Button("Muat Statistik", variant="primary", size="lg")
stats_out = gr.HTML(_empty_initial_msg("Klik tombol untuk menampilkan statistik."))
stats_btn.click(
fn=get_stats,
inputs=[],
outputs=stats_out,
show_progress="minimal",
)
with gr.Tab("Daftar Warkop"):
gr.HTML('<div class="panel-title">Lihat Semua Warkop</div>')
gr.HTML("""
<div class="panel-subtitle">
Jelajahi semua warkop dan urutkan sesuai kebutuhanmu.
</div>
""")
with gr.Row():
sort_dd = gr.Dropdown(
choices=[
"name",
"wifi_speed_mbps",
"price_range",
"noise_level",
"socket_availability",
"vibe_category",
"address",
],
value="name",
label="Urutkan berdasarkan",
scale=2,
)
asc_cb = gr.Checkbox(value=True, label="Urutan naik", scale=1)
all_btn = gr.Button("Lihat Semua Warkop", variant="primary", size="lg")
all_out = gr.HTML(_empty_initial_msg("Klik tombol untuk menampilkan semua data."))
all_btn.click(
fn=display_all,
inputs=[sort_dd, asc_cb],
outputs=all_out,
show_progress="minimal",
)
with gr.Tab("Tentang"):
gr.HTML(_render_about())
gr.HTML(
"""
<div class="app-footer">
<strong>Warkop Intelligence Finder</strong>
<span class="footer-divider">|</span>
Dibuat untuk membantu menemukan tempat yang sesuai kebutuhan pengguna
<span class="footer-divider">|</span>
<strong>Politeknik Negeri Lhokseumawe</strong>
</div>
"""
)
return app
# =============================================================================
# Main
# =============================================================================
if __name__ == "__main__":
app = build_app()
app.queue(
max_size=20,
default_concurrency_limit=4,
).launch(
server_name="0.0.0.0",
server_port=7860,
share=False,
show_error=True,
inbrowser=True,
)