ui: refactor navbar and improve mobile experience

- Move navigation menu to the leftmost position and
   consolidate actions into a dropdown.
 - Replace clunky mobile sheet handle with explicit
   minimize/maximize buttons.
 - Fix home page content margins for list elements.
 - Ensure student identity remains visible on mobile devices
   with optimized font sizing.
 - Synchronize CircuitJS simulator theme with global
   light/dark mode.
 - Update elemes.sh to provide cleaner output by silencing
   podman-compose logs by default.
master
a2nr 2026-04-22 21:04:38 +07:00
parent dc1d65ac15
commit dce4916b94
6 changed files with 167 additions and 97 deletions

View File

@ -5,6 +5,26 @@ PARENT_DIR="$(dirname "$SCRIPT_DIR")"
EXAMPLES_DIR="$SCRIPT_DIR/examples"
PROJECT_NAME="$(basename "$PARENT_DIR")"
# Check for --verbose flag
VERBOSE=0
for arg in "$@"; do
if [ "$arg" == "--verbose" ]; then
VERBOSE=1
break
fi
done
# Function to run podman-compose quietly unless verbose
run_compose() {
# Ensure we are in the script directory so podman-compose finds the yaml file
cd "$SCRIPT_DIR" || exit
if [ "$VERBOSE" -eq 1 ]; then
podman-compose -p "$PROJECT_NAME" --env-file "$PARENT_DIR/.env" "$@"
else
podman-compose -p "$PROJECT_NAME" --env-file "$PARENT_DIR/.env" "$@" >/dev/null 2>&1
fi
}
case "$1" in
init)
echo "✨ === Elemes Quick Start === ✨"
@ -61,7 +81,7 @@ init)
;;
stop | run | runbuild | runclearbuild)
echo "🛑 Menghentikan container yang sedang berjalan..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env down
run_compose down
;;&
stop)
echo "✅ Container berhasil dihentikan."
@ -70,20 +90,20 @@ runclearbuild)
echo "🧹 Membersihkan container dan image (prune)..."
podman image prune -f
echo "🏗️ Membangun ulang container dari awal (no-cache)..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env build --no-cache
run_compose build --no-cache
;;&
runbuild)
echo "🏗️ Membangun container..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env build
run_compose build
;;&
runbuild | runclearbuild)
echo "🚀 Menjalankan container di background..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env up --force-recreate -d
run_compose up --force-recreate -d
echo "✅ Elemes berhasil dijalankan!"
;;
run)
echo "🚀 Menjalankan container..."
podman-compose -p "$PROJECT_NAME" --env-file ../.env up
run_compose up -d
echo "✅ Elemes berhasil dijalankan!"
;;
generatetoken)
@ -93,6 +113,7 @@ generatetoken)
exportall)
echo "📦 === Mengekspor Semua Image (Pre-Compiled Bundle) ==="
TAR_FILE="lms-c-precompiled.tar"
cd "$SCRIPT_DIR" || exit
echo "🏗️ 1. Mem-build Backend Elemes..."
podman build -t lms-c-backend:latest -f Dockerfile .
@ -179,31 +200,6 @@ verify)
fi
done
;;
loadtest)
echo "📊 === Elemes Load Testing ==="
cd "$SCRIPT_DIR/load-test" || exit
if [ ! -d "env" ]; then
echo "⚙️ Membuat Python Virtual Environment (env/)..."
python3 -m venv env
fi
echo "⚙️ Mengaktifkan environment & menginstall requirements..."
source env/bin/activate
pip install -r requirements.txt >/dev/null 2>&1
echo "⚙️ Mempersiapkan Test Data & menginjeksi akun Bot..."
python3 content_parser.py --num-tokens 50
echo ""
echo "🚀 Memulai Locust Test Suite..."
echo "👉 Buka http://localhost:8089 di browser web milikmu."
echo "👉 Masukkan URL backend Elemes sebagai Host (contoh: http://localhost:5000)"
echo "👉 Tekan CTRL+C di terminal ini untuk menghentikan test."
echo ""
locust -f locustfile.py
;;
*)
echo "💡 Cara Penggunaan elemes.sh:"
echo " ./elemes.sh init # Inisialisasi konfigurasi, folder, & data tokens"

View File

@ -15,8 +15,10 @@ function loadCircuitEmbed(div: HTMLElement) {
wrapper.style.width = width;
wrapper.style.height = height;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const iframe = document.createElement('iframe');
iframe.src = '/circuitjs1/circuitjs.html';
iframe.src = `/circuitjs1/circuitjs.html?whiteBackground=${!isDark}`;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';

View File

@ -1,6 +1,7 @@
<script lang="ts">
import CrosshairOverlay from './CrosshairOverlay.svelte';
import type { CircuitJSApi } from '$lib/types/circuitjs';
import { themeDark } from '$stores/theme';
interface Props {
initialCircuit?: string;
@ -17,6 +18,7 @@
let saveTimeout: ReturnType<typeof setTimeout>;
let autoSaveInterval: ReturnType<typeof setInterval>;
let lastLoadedCircuit = $state('');
let lastStorageKey = $state<string | undefined>(undefined);
function saveToStorage(text: string) {
if (!storageKey) return;
@ -152,6 +154,8 @@
export function getApi(): CircuitJSApi | null {
return simApi;
}
let iframeSrc = $derived(`/circuitjs1/circuitjs.html?whiteBackground=${!$themeDark}`);
</script>
<div class="circuit-container">
@ -162,7 +166,7 @@
<iframe
bind:this={iframe}
src="/circuitjs1/circuitjs.html"
src={iframeSrc}
title="Circuit Simulator"
onload={handleIframeLoad}
class:visible={ready}

View File

@ -5,6 +5,7 @@
import ProgressBadge from '$components/ProgressBadge.svelte';
import { env } from '$env/dynamic/public';
let showDropdown = $state(false);
let showLoginModal = $state(false);
let tokenInput = $state('');
let loginError = $state('');
@ -31,47 +32,72 @@
}
</script>
<svelte:window onclick={() => (showDropdown = false)} />
<nav class="navbar" onclickcapture={() => auth.recordActivity()}>
<div class="container navbar-inner">
{#if $lessonContext}
<!-- Lesson mode -->
<div class="navbar-left">
<div class="nav-dropdown">
<button type="button" class="btn-icon-sm dropdown-toggle" onclick={(e) => { e.stopPropagation(); showDropdown = !showDropdown; }} title="Menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
</button>
{#if showDropdown}
<div class="dropdown-menu" onclick={(e) => e.stopPropagation()}>
<button type="button" class="dropdown-item" onclick={() => { theme.toggle(); showDropdown = false; }}>
{#if $themeDark}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
Tema Terang
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
Tema Gelap
{/if}
</button>
<a href="/help" target="_blank" class="dropdown-item" onclick={() => (showDropdown = false)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
Bantuan
</a>
<div class="dropdown-divider"></div>
{#if $authLoggedIn}
<button type="button" class="dropdown-item" onclick={() => { auth.logout(); showDropdown = false; }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
Keluar
</button>
{:else}
<button type="button" class="dropdown-item" onclick={() => { (showLoginModal = true); (showDropdown = false); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg>
Masuk
</button>
{/if}
</div>
{/if}
</div>
{#if $lessonContext}
<a href="/" class="nav-home-btn" title="Semua Pelajaran">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
</a>
{:else}
<a href="/" class="navbar-brand">{env.PUBLIC_APP_BAR_TITLE || 'Elemes LMS'}</a>
{/if}
{#if $lessonContext}
<h1 class="navbar-lesson-title">{$lessonContext.title}</h1>
{#if $lessonContext.completed && $authLoggedIn}
<ProgressBadge completed={true} />
{/if}
</div>
{:else}
<!-- Home mode -->
<a href="/" class="navbar-brand">{env.PUBLIC_APP_BAR_TITLE || 'Elemes LMS'}</a>
{/if}
</div>
<div class="navbar-actions">
{#if $authLoggedIn}
<span class="user-label">{$authStudentName}</span>
{/if}
{#if $lessonContext?.nextLesson}
<a href="/lesson/{$lessonContext.nextLesson.filename}" class="btn btn-nav-next" title="{$lessonContext.nextLesson.title}">
{$lessonContext.nextLesson.title} &rsaquo;
</a>
{/if}
<button class="btn-icon-sm" onclick={() => theme.toggle()} title="Toggle tema">
{$themeDark ? '\u2600\uFE0F' : '\uD83C\uDF19'}
</button>
<a href="/help" target="_blank" class="nav-help-link" title="Panduan Penggunaan">
Bantuan
</a>
{#if $authLoggedIn}
<span class="user-label">{$authStudentName}</span>
<button class="btn btn-danger btn-xs" onclick={() => auth.logout()}>Keluar</button>
{:else}
<button class="btn btn-primary btn-xs" onclick={() => (showLoginModal = true)}>
Masuk
</button>
{/if}
</div>
</div>
</nav>
@ -163,6 +189,63 @@
line-height: 1.3;
}
/* ── Dropdown ──────────────────────────────────── */
.nav-dropdown {
position: relative;
display: flex;
align-items: center;
margin-right: 0.25rem;
}
.dropdown-toggle {
color: rgba(255, 255, 255, 0.85);
transition: color 0.15s;
display: flex;
align-items: center;
}
.dropdown-toggle:hover {
color: #fff;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 160px;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 200;
}
.dropdown-item {
padding: 0.6rem 1rem;
background: none;
border: none;
color: var(--color-text);
text-align: left;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.15s;
}
.dropdown-item:hover {
background: var(--color-bg-secondary);
color: var(--color-primary);
text-decoration: none;
}
.dropdown-divider {
height: 1px;
background: var(--color-border);
margin: 0.25rem 0;
}
/* ── Right section ─────────────────────────────── */
.navbar-actions {
display: flex;
@ -193,6 +276,10 @@
.user-label {
font-size: 0.8rem;
opacity: 0.9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.btn-icon-sm {
background: none;
@ -266,16 +353,21 @@
/* ── Mobile ─────────────────────────────────────── */
@media (max-width: 768px) {
.navbar-inner {
gap: 0.35rem;
}
.navbar-lesson-title {
font-size: 0.9rem;
}
.user-label {
display: none;
display: inline-block;
font-size: 0.65rem;
max-width: 80px;
}
.btn-nav-next {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
max-width: 100px;
max-width: 90px;
}
}
</style>

View File

@ -44,12 +44,6 @@
const hasMultiLang = $derived(hasC && hasPython);
const hasCircuit = $derived(activeTabs?.includes('circuit') ?? false);
function cycleMobileSheet() {
if (mobileMode === 'hidden') mobileMode = 'half';
else if (mobileMode === 'half') mobileMode = 'full';
else mobileMode = 'hidden';
}
function onSheetTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY;
}
@ -94,15 +88,16 @@
{#if isMobile}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="panel-header sheet-handle"
<div class="panel-header"
ontouchstart={onSheetTouchStart}
ontouchend={onSheetTouchEnd}>
<button type="button" class="sheet-handle-bar-btn" onclick={cycleMobileSheet} aria-label="Resize panel">
<div class="sheet-handle-bar"></div>
</button>
<div class="chrome-tabs">
{@render chromeTabs()}
</div>
<div class="panel-actions">
<button type="button" class="panel-btn" onclick={(e) => { e.stopPropagation(); if (mobileMode === 'full') mobileMode = 'half'; else if (mobileMode === 'half') mobileMode = 'hidden'; }} title="Minimize" disabled={mobileMode === 'hidden'}>▽</button>
<button type="button" class="panel-btn" onclick={(e) => { e.stopPropagation(); if (mobileMode === 'hidden') mobileMode = 'half'; else if (mobileMode === 'half') mobileMode = 'full'; }} title="Maximize" disabled={mobileMode === 'full'}>△</button>
</div>
</div>
{:else if floating && !minimized}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@ -232,31 +227,4 @@
border-color: var(--color-border);
z-index: 1;
}
/* ── Mobile sheet handle ────────────────────────────── */
.sheet-handle {
flex-direction: column;
align-items: stretch;
border: none;
border-bottom: none;
width: 100%;
color: inherit;
font: inherit;
padding: 4px 8px 0;
}
.sheet-handle-bar-btn {
display: block;
width: 100%;
background: none;
border: none;
padding: 4px 0;
cursor: pointer;
}
.sheet-handle-bar {
width: 36px;
height: 4px;
background: var(--color-border);
border-radius: 2px;
margin: 0 auto;
}
</style>

View File

@ -29,6 +29,14 @@
.home-intro {
margin-bottom: 2rem;
}
.home-intro :global(ul),
.home-intro :global(ol) {
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
.home-intro :global(li) {
margin-bottom: 0.25rem;
}
.lesson-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));