"""
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'
{msg}
'
def _no_recommender_msg() -> str:
detail = escape(str(CSV_PATH))
return _status_msg(
f"Recommender belum berhasil dimuat. Cek file {detail} dan format dataset.",
"error",
)
def _empty_initial_msg(msg: str) -> str:
return f'{escape(msg)}
'
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''
for i in range(3)
)
return f'{bars}
'
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'{int(speed)} Mbps'
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'Cocok {_safe_float(row.get("match_score")):.0f}%
'
elif "similarity_score" in row.index and pd.notna(row.get("similarity_score")):
score_html = f'Mirip {_safe_float(row.get("similarity_score")):.0f}%
'
rank_html = f'#{rank}
' if rank is not None else ""
vibe_tags = "".join(
f'{escape(tag.strip())}'
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"""
{summary}
{vibe_tags or 'Nyaman'}
Internet
{_render_wifi_indicator(wifi)}
Colokan
{_render_level_bars(socket)}
Suasana
{_render_level_bars(noise)}
Harga
{PRICE_SYMBOLS.get(price_key, "?")}
"""
def _render_cards(df: Optional[pd.DataFrame], empty_msg: str = "Belum ada hasil") -> str:
if df is None or df.empty:
return f"""
{escape(empty_msg)}
Coba ubah filter atau query Anda
"""
cards = []
for idx, (_, row) in enumerate(df.iterrows(), start=1):
cards.append(_render_card(row, rank=idx))
return f'{"".join(cards)}
'
# =============================================================================
# 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 (
''
f'
{escape(str(label))}
'
f'
{escape(_safe_str(val_a))}
'
f'
{escape(_safe_str(val_b))}
'
"
"
)
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"""
"""
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'{escape(str(k))}{escape(str(v))}
'
for k, v in top_vibes.items()
)
return f"""
{escape(str(s.get('Total Warkop', 0)))}
Total Warkops Tersedia di Lhokseumawe
WiFi Tercepat
{_safe_float(s.get('WiFi Tercepat (Mbps)', 0)):.0f} Mbps
WiFi Rata-rata
{_safe_float(s.get('WiFi Rata-rata (Mbps)', 0)):.1f} Mbps
Spot Tenang
{escape(_safe_str(s.get('Spot Paling Tenang', '-')))}
Budget Friendly
{escape(_safe_str(s.get('Budget Friendly', '-')))}
Premium
{escape(_safe_str(s.get('Premium', '-')))}
Colokan Melimpah
{escape(_safe_str(s.get('Socket Melimpah', '-')))}
Top Vibe Categories
{top_vibes_html or '
Tidak ada data vibe.
'}
"""
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"""
{escape(title)}
{escape(desc)}
"""
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"""
{escape(tech)}
{escape(func)}
"""
for tech, func in tech_rows
)
total = len(_safe_df())
return f"""
Tentang Aplikasi
{escape(APP_TITLE)} adalah sistem rekomendasi cerdas
untuk membantu pengguna menemukan warkop terbaik di Lhokseumawe
sesuai kebutuhan pribadi, baik untuk nugas, nongkrong, meeting, maupun santai.
Algoritma
score = w_wifi * wifi_normalized
+ w_socket * socket_normalized
+ w_quiet * (1 - noise_normalized)
+ w_value * (1 - price_normalized)
final = text_ratio * cosine_sim(query, metadata)
+ (1 - text_ratio) * numeric_score
sim = (cosine_sim_numeric + cosine_sim_text) / 2
Schema Data
address
string
Alamat / kawasan
wifi_speed_mbps
number
0 - 100+
socket_availability
enum
Low / Medium / High
noise_level
enum
Low / Medium / High
vibe_category
string
Tag slash-separated
price_range
enum
Cheap / Medium / Expensive
"""
# =============================================================================
# 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"""
"""
)
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('Atur Kebutuhan Kamu
')
gr.HTML('Pilih yang paling penting buat kamu, lalu kami bantu carikan warkop yang cocok.
')
gr.HTML("""
Cara pakai
1. Pilih preset cepat atau atur slider sendiri
2. Tambahkan filter harga atau suasana kalau perlu
3. Klik Cari Sekarang
""")
gr.HTML("PRESET CEPAT")
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("
")
gr.HTML("ATUR MANUAL")
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("
")
gr.HTML("FILTER TAMBAHAN")
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('Hasil Rekomendasi
')
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('Ceritakan Warkop yang Kamu Cari
')
gr.HTML("""
Tulis dengan bahasa santai, seperti sedang cerita ke teman.
""")
gr.HTML("""
Contoh: "Saya cari tempat tenang buat kerja, internet cepat, dan colokan banyak."
""")
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("
")
gr.HTML("CONTOH PENCARIAN")
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('Hasil Pencarian
')
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('Gabungkan Cerita dan Preferensi
')
gr.HTML("""
Cocok kalau kamu ingin hasil yang lebih personal: deskripsi + prioritas.
""")
gr.HTML("""
Cocok dipakai kalau
• Kamu punya gambaran tempat yang diinginkan
• Kamu juga ingin mengatur prioritas seperti WiFi, harga, atau suasana
""")
h_query = gr.Textbox(
label="Deskripsi (opsional)",
placeholder="Contoh: warkop modern, tenang, cocok buat kerja di area Pase",
lines=4,
)
gr.HTML("PRIORITAS KAMU")
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("
")
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('Hasil Pencarian
')
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('Pilih Suasana yang Kamu Mau
')
gr.HTML("""
Cocok kalau kamu sudah tahu vibe tempat yang diinginkan.
""")
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('Hasil
')
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('Cari Warkop yang Mirip
')
gr.HTML("""
Sudah punya warkop favorit? Pilih di bawah, lalu kami carikan yang mirip.
""")
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('Warkop Serupa
')
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('Cari Berdasarkan Lokasi
')
gr.HTML("""
Lihat warkop berdasarkan kawasan atau alamat tertentu.
""")
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('Hasil
')
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('Bandingkan Dua Warkop
')
gr.HTML("""
Cocok untuk membantu memilih tempat sebelum berangkat.
""")
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('Ringkasan Data Warkop
')
gr.HTML("""
Lihat gambaran umum seluruh data warkop yang tersedia.
""")
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('Lihat Semua Warkop
')
gr.HTML("""
Jelajahi semua warkop dan urutkan sesuai kebutuhanmu.
""")
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(
"""
"""
)
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,
)