Merge pull request 'Optimize images and convert PNG to WebP' (#4) from feature/optimize-images into main
Reviewed-on: #4 Reviewed-by: requestha <d1041231091@student.untan.ac.id> Reviewed-by: pancaa <d1041231077@student.untan.ac.id>
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 337 KiB |
@@ -1,3 +1,29 @@
|
||||
/**
|
||||
* localization.js — HarusSelesaiKP
|
||||
*
|
||||
* ANTI-FLICKER STRATEGY:
|
||||
* 1. IIFE `initLanguage()` runs SYNCHRONOUSLY (no defer/async) to set
|
||||
* `window.__currentLang` before the browser paints any content.
|
||||
* 2. HTML body carries `lang-pending` class; body is hidden via CSS until
|
||||
* `applyTranslations()` fires on DOMContentLoaded and removes the class.
|
||||
* 3. `languageChanged` CustomEvent signals dynamic sections (Facilities,
|
||||
* Campus, Culinary, LivingCost) to re-render in the new language.
|
||||
*/
|
||||
|
||||
// ─── 1. Determine language SYNCHRONOUSLY (before first paint) ────────────────
|
||||
(function initLanguage() {
|
||||
try {
|
||||
const saved = localStorage.getItem("lang");
|
||||
const browserLang = navigator.language?.startsWith("id") ? "id" : "en";
|
||||
window.__currentLang = saved || browserLang;
|
||||
} catch (e) {
|
||||
window.__currentLang = "id";
|
||||
}
|
||||
// Stamp <html lang> immediately so screen readers get the right language
|
||||
document.documentElement.lang = window.__currentLang;
|
||||
})();
|
||||
|
||||
// ─── 2. Translation data ─────────────────────────────────────────────────────
|
||||
const translations = {
|
||||
id: {
|
||||
// Kelompok HarusSelesaiKP (skp)
|
||||
@@ -330,3 +356,76 @@ const translations = {
|
||||
skp_cost_type_transport: "Transport",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 3. Public translation helper ────────────────────────────────────────────
|
||||
/**
|
||||
* t(key) — returns translated string for the active language.
|
||||
* Falls back to Indonesian if key is missing in the active language.
|
||||
*/
|
||||
function t(key) {
|
||||
const lang = window.__currentLang || "id";
|
||||
return translations[lang]?.[key] ?? translations["id"]?.[key] ?? key;
|
||||
}
|
||||
|
||||
// ─── 4. Apply translations to all [data-i18n] elements ───────────────────────
|
||||
function applyTranslations(lang) {
|
||||
window.__currentLang = lang;
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
try {
|
||||
localStorage.setItem("lang", lang);
|
||||
} catch (e) {
|
||||
// localStorage may be blocked (private mode, etc.) — fail silently
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
const key = el.dataset.i18n;
|
||||
const text = t(key);
|
||||
if (text && text !== key) {
|
||||
el.textContent = text;
|
||||
}
|
||||
});
|
||||
|
||||
// Signal dynamic components to re-render
|
||||
document.dispatchEvent(new CustomEvent("languageChanged"));
|
||||
}
|
||||
|
||||
// ─── 5. Toggle language (called by UI buttons) ───────────────────────────────
|
||||
function toggleLanguage() {
|
||||
const next = window.__currentLang === "id" ? "en" : "id";
|
||||
applyTranslations(next);
|
||||
updateLanguageButtonUI(next);
|
||||
}
|
||||
|
||||
function updateLanguageButtonUI(lang) {
|
||||
document.querySelectorAll(".language-toggle-btn").forEach((btn) => {
|
||||
const idSpan = btn.querySelector(".lang-id");
|
||||
const enSpan = btn.querySelector(".lang-en");
|
||||
if (idSpan && enSpan) {
|
||||
if (lang === "id") {
|
||||
idSpan.className = "lang-id font-bold text-white";
|
||||
enSpan.className = "lang-en font-normal opacity-50";
|
||||
} else {
|
||||
idSpan.className = "lang-id font-normal opacity-50";
|
||||
enSpan.className = "lang-en font-bold text-white";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 6. Boot on DOMContentLoaded ─────────────────────────────────────────────
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Apply translations to all static [data-i18n] nodes
|
||||
applyTranslations(window.__currentLang);
|
||||
|
||||
// Sync language button label
|
||||
updateLanguageButtonUI(window.__currentLang);
|
||||
|
||||
// Wire up toggle buttons
|
||||
document.querySelectorAll(".language-toggle-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", toggleLanguage);
|
||||
});
|
||||
|
||||
// ── Reveal body: remove anti-flicker class after translations applied ──
|
||||
document.body.classList.remove("lang-pending");
|
||||
});
|
||||
|
||||
@@ -1604,3 +1604,300 @@ body {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ANTI-FLICKER — localization.js sets window.__currentLang
|
||||
synchronously; body stays hidden until DOMContentLoaded
|
||||
fires applyTranslations() and removes this class.
|
||||
============================================================ */
|
||||
body.lang-pending {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MOVED FROM index.html <style> TAG
|
||||
(Originally inline; moved here so the browser can cache it)
|
||||
============================================================ */
|
||||
|
||||
/* ===== MOBILE NAV STABILITY FIX =====
|
||||
Prevents logo bar from collapsing/shifting on Android when
|
||||
address bar hides/shows and triggers viewport reflow.
|
||||
===================================== */
|
||||
#nav-wrapper {
|
||||
position: fixed !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
will-change: box-shadow;
|
||||
}
|
||||
|
||||
#nav-wrapper > div:first-child {
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
#nav-wrapper > div:first-child {
|
||||
min-height: 0 !important;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
#pengumuman-bar {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
#pengumuman-bar.is-stuck {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skip Link Styling */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: #003150;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
z-index: 100;
|
||||
transition: top 0.3s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Focus visible for better keyboard navigation */
|
||||
:focus-visible {
|
||||
outline: 3px solid #feb401;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mega Menu Styles */
|
||||
.hero-pagination .swiper-pagination-bullet {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-pagination .swiper-pagination-bullet-active {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mega-panel {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.mega-panel.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Overlay transition */
|
||||
#mega-overlay {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease;
|
||||
}
|
||||
|
||||
#mega-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mega-link-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mega-link-card:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.mega-link-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-menu-btn.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.nav-menu-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Mega menu positioning - no gap */
|
||||
#main-nav {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#mega-menu-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#mega-menu-container .mega-panel {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Keep pointer bridge under nav to prevent hover loss */
|
||||
.nav-menu-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-menu-item::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* ===== Luco Chatbot Animations ===== */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lucoPulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
#luco-container {
|
||||
animation: slideInRight 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
#luco-bubble {
|
||||
animation: fadeInUp 0.3s ease-out forwards;
|
||||
width: min(18rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
#luco-btn {
|
||||
animation: lucoPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#luco-btn:hover {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.typing-indicator span {
|
||||
display: inline-block;
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Luco speech bubble arrow */
|
||||
#luco-bubble .bubble-arrow {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: white;
|
||||
transform: rotate(45deg);
|
||||
border-right: 1px solid #e2e8f0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||