Improve SSE row highlight and pluk audio
This commit is contained in:
@@ -5,12 +5,154 @@
|
||||
{{$proj := .Project}}
|
||||
<style>
|
||||
@keyframes sseFlash {
|
||||
0% { box-shadow: 0 0 0 0 rgba(59, 80, 160, 0.45); background-color: #eef2ff; }
|
||||
100% { box-shadow: 0 0 0 0 rgba(59, 80, 160, 0); background-color: transparent; }
|
||||
0% {
|
||||
box-shadow: inset 0 0 0 2px rgba(59, 80, 160, 0.68);
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 0 0 0 rgba(59, 80, 160, 0);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sseSweep {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(120%); }
|
||||
}
|
||||
|
||||
@keyframes ssePop {
|
||||
0% { transform: scale(1); }
|
||||
22% { transform: scale(1.022); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes stationRowPulse {
|
||||
0% {
|
||||
background-color: #dbeafe;
|
||||
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.55);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
box-shadow: inset 0 0 0 0 rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arrivalRowPulse {
|
||||
0% {
|
||||
background-color: #e0e7ff;
|
||||
box-shadow: inset 0 0 0 1px rgba(79, 70, 229, 0.45);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
box-shadow: inset 0 0 0 0 rgba(79, 70, 229, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.sse-anim-target {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
transform-origin: center;
|
||||
will-change: transform, box-shadow, background-color;
|
||||
transition: background-color .2s ease, box-shadow .2s ease, transform .2s ease;
|
||||
}
|
||||
|
||||
.sse-anim-target > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sse-updated {
|
||||
animation: sseFlash 3.8s ease-out;
|
||||
animation: sseFlash 2.8s ease-out, ssePop 0.95s cubic-bezier(.22,.61,.36,1);
|
||||
}
|
||||
|
||||
.sse-updated::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
background: rgba(59, 80, 160, 0.22);
|
||||
animation: sseFlash 2.8s ease-out;
|
||||
}
|
||||
|
||||
.sse-updated::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -45%;
|
||||
width: 45%;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, rgba(59, 80, 160, 0), rgba(59, 80, 160, 0.35), rgba(59, 80, 160, 0));
|
||||
animation: sseSweep 1.1s ease-out 1;
|
||||
}
|
||||
|
||||
.sse-running-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sse-running-bar::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 40%;
|
||||
background: linear-gradient(90deg, rgba(59, 80, 160, 0), rgba(59, 80, 160, 0.95), rgba(59, 80, 160, 0));
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
|
||||
.sse-updated .sse-running-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sse-updated .sse-running-bar::after {
|
||||
animation: sseSweep 1.05s linear 1;
|
||||
}
|
||||
|
||||
.sse-updating-label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .03em;
|
||||
color: #1e3a8a;
|
||||
background: rgba(191, 219, 254, 0.92);
|
||||
border: 1px solid rgba(147, 197, 253, 0.95);
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sse-updated .sse-updating-label {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
#sse-stations.sse-updated {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#sse-stations .station-row-updated td {
|
||||
animation: stationRowPulse 2.6s ease-out;
|
||||
}
|
||||
|
||||
#sse-arrivals .arrival-row-updated {
|
||||
animation: arrivalRowPulse 2.6s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,8 +213,10 @@
|
||||
</section>
|
||||
|
||||
<!-- KPI Cards — SSE swap -->
|
||||
<section id="sse-kpi" class="grid gap-4 sm:grid-cols-3"
|
||||
<section id="sse-kpi" class="grid gap-4 sm:grid-cols-3 sse-anim-target"
|
||||
sse-swap="kpi" hx-swap="innerHTML">
|
||||
<span class="sse-running-bar" aria-hidden="true"></span>
|
||||
<span class="sse-updating-label" aria-hidden="true">Updating...</span>
|
||||
{{range $i := seq 3}}
|
||||
<div class="card h-28 animate-pulse bg-slate-50"></div>
|
||||
{{end}}
|
||||
@@ -129,8 +273,10 @@
|
||||
<!-- Station Status + Arrival List — SSE swap -->
|
||||
<section class="grid gap-5 xl:grid-cols-3">
|
||||
|
||||
<article id="sse-stations" class="card xl:col-span-2 overflow-hidden"
|
||||
<article id="sse-stations" class="card xl:col-span-2 overflow-hidden sse-anim-target"
|
||||
sse-swap="stations" hx-swap="innerHTML">
|
||||
<span class="sse-running-bar" aria-hidden="true"></span>
|
||||
<span class="sse-updating-label" aria-hidden="true">Updating...</span>
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Station Status</h2>
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-slate-400 animate-pulse">
|
||||
@@ -140,8 +286,10 @@
|
||||
<div class="p-5 text-sm text-slate-400">Memuat data...</div>
|
||||
</article>
|
||||
|
||||
<article id="sse-arrivals" class="card overflow-hidden"
|
||||
<article id="sse-arrivals" class="card overflow-hidden sse-anim-target"
|
||||
sse-swap="arrivals" hx-swap="innerHTML">
|
||||
<span class="sse-running-bar" aria-hidden="true"></span>
|
||||
<span class="sse-updating-label" aria-hidden="true">Updating...</span>
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Arrival List</h2>
|
||||
<a href="{{b "/arrival"}}" class="text-xs font-medium text-brand-500 hover:text-brand-700">View all</a>
|
||||
@@ -165,7 +313,6 @@
|
||||
</section>
|
||||
|
||||
</div><!-- end SSE wrapper -->
|
||||
|
||||
<!-- Modal: Semua Pasien -->
|
||||
<dialog id="patients-modal"
|
||||
class="w-full max-w-6xl rounded-2xl border border-slate-200 bg-white shadow-2xl p-0 backdrop:bg-slate-900/50"
|
||||
@@ -231,6 +378,197 @@ function openPatientsModal() {
|
||||
|
||||
(function() {
|
||||
const sseTargets = new Set(['sse-kpi', 'sse-stations', 'sse-arrivals']);
|
||||
const sseEventTargetMap = {
|
||||
kpi: 'sse-kpi',
|
||||
stations: 'sse-stations',
|
||||
arrivals: 'sse-arrivals'
|
||||
};
|
||||
const stationSnapshot = new Map();
|
||||
const arrivalSnapshot = new Set();
|
||||
let audioCtx = null;
|
||||
let audioUnlocked = false;
|
||||
let pendingPluk = false;
|
||||
|
||||
function ensureAudioContext() {
|
||||
if (audioCtx) return audioCtx;
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctx) return null;
|
||||
audioCtx = new Ctx();
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
function unlockAudio() {
|
||||
const ctx = ensureAudioContext();
|
||||
if (!ctx) return;
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().catch(function () {});
|
||||
}
|
||||
audioUnlocked = true;
|
||||
if (pendingPluk) {
|
||||
pendingPluk = false;
|
||||
setTimeout(playPluk, 60);
|
||||
}
|
||||
}
|
||||
|
||||
function playPluk() {
|
||||
const ctx = ensureAudioContext();
|
||||
if (!ctx) return;
|
||||
if (!audioUnlocked) {
|
||||
pendingPluk = true;
|
||||
return;
|
||||
}
|
||||
if (ctx.state !== 'running') {
|
||||
pendingPluk = true;
|
||||
ctx.resume()
|
||||
.then(function () {
|
||||
setTimeout(function () {
|
||||
if (pendingPluk) {
|
||||
pendingPluk = false;
|
||||
playPluk();
|
||||
}
|
||||
}, 40);
|
||||
})
|
||||
.catch(function () {});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const lp = ctx.createBiquadFilter();
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(860, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(530, now + 0.12);
|
||||
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.setValueAtTime(1800, now);
|
||||
lp.Q.value = 0.8;
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.3, now + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.24);
|
||||
|
||||
osc.connect(lp);
|
||||
lp.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.26);
|
||||
}
|
||||
|
||||
function triggerStationRows(target) {
|
||||
const rows = target.querySelectorAll('.station-row');
|
||||
if (!rows || !rows.length) return;
|
||||
let highlighted = 0;
|
||||
rows.forEach(function (row, idx) {
|
||||
const key = row.dataset.stationKey || '';
|
||||
const processed = parseInt(row.dataset.processed || '0', 10);
|
||||
const prevProcessed = stationSnapshot.has(key) ? stationSnapshot.get(key) : null;
|
||||
stationSnapshot.set(key, processed);
|
||||
if (prevProcessed === null || processed <= prevProcessed) return;
|
||||
|
||||
highlighted++;
|
||||
row.classList.remove('station-row-updated');
|
||||
void row.offsetWidth;
|
||||
setTimeout(function () {
|
||||
row.classList.add('station-row-updated');
|
||||
}, Math.min(idx * 70, 420));
|
||||
setTimeout(function () {
|
||||
row.classList.remove('station-row-updated');
|
||||
}, 2800 + Math.min(idx * 70, 420));
|
||||
});
|
||||
return highlighted > 0;
|
||||
}
|
||||
|
||||
function triggerArrivalRows(target) {
|
||||
const rows = target.querySelectorAll('.arrival-row');
|
||||
if (!rows || !rows.length) return;
|
||||
let highlighted = 0;
|
||||
rows.forEach(function (row, idx) {
|
||||
const key = row.dataset.arrivalKey || '';
|
||||
const isNew = key && !arrivalSnapshot.has(key);
|
||||
if (key) arrivalSnapshot.add(key);
|
||||
if (!isNew) return;
|
||||
|
||||
highlighted++;
|
||||
row.classList.remove('arrival-row-updated');
|
||||
void row.offsetWidth;
|
||||
setTimeout(function () {
|
||||
row.classList.add('arrival-row-updated');
|
||||
}, Math.min(idx * 70, 420));
|
||||
setTimeout(function () {
|
||||
row.classList.remove('arrival-row-updated');
|
||||
}, 2800 + Math.min(idx * 70, 420));
|
||||
});
|
||||
return highlighted > 0;
|
||||
}
|
||||
|
||||
function triggerHighlight(target) {
|
||||
if (!target) return;
|
||||
const isStations = target.id === 'sse-stations';
|
||||
let didHighlightRows = false;
|
||||
if (!isStations) {
|
||||
if (target.id === 'sse-arrivals') {
|
||||
didHighlightRows = !!triggerArrivalRows(target);
|
||||
} else {
|
||||
target.classList.remove('sse-updated');
|
||||
void target.offsetWidth;
|
||||
target.classList.add('sse-updated');
|
||||
didHighlightRows = true;
|
||||
}
|
||||
} else {
|
||||
didHighlightRows = !!triggerStationRows(target);
|
||||
}
|
||||
if (didHighlightRows) {
|
||||
playPluk();
|
||||
}
|
||||
clearTimeout(target._sseHighlightTimer);
|
||||
target._sseHighlightTimer = setTimeout(function () {
|
||||
target.classList.remove('sse-updated');
|
||||
}, 2800);
|
||||
}
|
||||
|
||||
function setupMutationHighlight(targetId) {
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
let armed = false;
|
||||
let lastTriggerAt = 0;
|
||||
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
if (!armed) {
|
||||
armed = true;
|
||||
return;
|
||||
}
|
||||
const hasRealChange = mutations.some(function (m) {
|
||||
return (m.type === 'childList' && (m.addedNodes.length || m.removedNodes.length)) ||
|
||||
(m.type === 'characterData' && m.oldValue !== m.target.data);
|
||||
});
|
||||
if (!hasRealChange) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastTriggerAt < 800) return;
|
||||
lastTriggerAt = now;
|
||||
const beforeClass = target.className;
|
||||
triggerHighlight(target);
|
||||
// Fallback audio for edge cases where row diff doesn't mark highlight.
|
||||
if ((targetId === 'sse-stations' || targetId === 'sse-arrivals') && beforeClass === target.className) {
|
||||
playPluk();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(target, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
characterDataOldValue: true
|
||||
});
|
||||
}
|
||||
|
||||
// Browser policy: audio baru bisa aktif setelah interaksi user.
|
||||
['pointerdown', 'mousedown', 'keydown', 'touchstart', 'focus'].forEach(function (evtName) {
|
||||
window.addEventListener(evtName, unlockAudio, { passive: true, once: true });
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
||||
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
|
||||
@@ -242,11 +580,29 @@ function openPatientsModal() {
|
||||
return;
|
||||
}
|
||||
|
||||
target.classList.remove('sse-updated');
|
||||
void target.offsetWidth;
|
||||
target.classList.add('sse-updated');
|
||||
triggerHighlight(target);
|
||||
});
|
||||
|
||||
// Fallback trigger: beberapa setup SSE HTMX tidak selalu menembakkan afterSwap seperti yang diharapkan.
|
||||
document.body.addEventListener('htmx:sseMessage', function (evt) {
|
||||
const eventName = evt.detail && evt.detail.event ? evt.detail.event : '';
|
||||
const targetId = sseEventTargetMap[eventName];
|
||||
if (!targetId) return;
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
// Skip first SSE frame per target, then animate on subsequent updates.
|
||||
if (!target.dataset.sseHydrated) {
|
||||
target.dataset.sseHydrated = '1';
|
||||
return;
|
||||
}
|
||||
triggerHighlight(target);
|
||||
});
|
||||
|
||||
// Hard fallback: pastikan perubahan konten section tetap men-trigger efek meski event SSE/HTMX tidak konsisten.
|
||||
setupMutationHighlight('sse-kpi');
|
||||
setupMutationHighlight('sse-stations');
|
||||
setupMutationHighlight('sse-arrivals');
|
||||
|
||||
const palette = ['#3b50a0', '#6677d6', '#10b981', '#f59e0b'];
|
||||
const tatData = {{.TATChart}};
|
||||
const trendData = {{.TrendChart}};
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
{{if .Rows}}
|
||||
<ul class="divide-y divide-slate-50 px-3 py-2">
|
||||
{{range .Rows}}
|
||||
<li class="flex items-center gap-3 rounded-xl px-2 py-2.5 transition-colors hover:bg-slate-50">
|
||||
<li class="arrival-row flex items-center gap-3 rounded-xl px-2 py-2.5 transition-colors hover:bg-slate-50"
|
||||
data-arrival-key="{{.Name}}|{{.Date}}|{{.InTime}}|{{.Station}}">
|
||||
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600">
|
||||
{{.Name | initials}}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
{{range .Rows}}
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<tr class="station-row hover:bg-slate-50 transition-colors"
|
||||
data-station-key="{{.Station | stationShort}}"
|
||||
data-processed="{{.Processed}}"
|
||||
data-pending="{{.Pending}}"
|
||||
data-total="{{.Total}}">
|
||||
<td class="px-3 py-2.5 font-medium text-slate-700">
|
||||
{{.Station | stationShort}}
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user