Files
E-Vote/resources/assets/js/components/demo/RouletteEliminationWheelDemo.vue
T

582 lines
14 KiB
Vue

<template>
<div class="roulette-demo-page container-fluid">
<header class="roulette-page-header">
<div class="roulette-page-header-inner">
<Logo :href="'/admin/'" />
<h1 class="roulette-demo-title">Sesi Cabutan Bertuah Kehadiran Fizikal</h1>
<button type="button" class="btn btn-default btn-sm roulette-bg-music-btn"
:aria-pressed="bgMusicPlaying ? 'true' : 'false'"
:aria-label="bgMusicPlaying ? 'Pause Background Music' : 'Play Background Music'"
@click="toggleBgMusic">
{{ bgMusicPlaying ? 'Pause Background Music' : 'Play Background Music' }}
</button>
</div>
</header>
<div v-if="loadError" class="alert alert-danger">
{{ loadError }}
</div>
<div v-else-if="loading" class="text-muted roulette-demo-loading">
Memuat senarai pengundi Fizikal
</div>
<div v-else-if="!apiVoters.length" class="alert alert-info">
Tiada pengundi Fizikal direkod buat masa ini. Semak kehadiran atau tambah rekod.
</div>
<div v-if="!loading && !loadError && apiVoters.length" class="roulette-sync-bar">
<span v-if="lastSyncedAt" class="roulette-sync-time">
Senarai menunggu dikemas kini pada <strong>{{ lastSyncedFormatted }}</strong>
</span>
<span v-if="refreshing" class="roulette-sync-refreshing text-muted">Mengemas kini</span>
<span v-if="pollError" class="text-warning roulette-sync-poll-error">{{ pollError }}</span>
<button type="button" class="btn btn-default btn-sm roulette-sync-btn" :disabled="refreshing"
@click="fetchFizikalVoters(false)">
Segerakan sekarang
</button>
</div>
<div v-if="!loading && !loadError && apiVoters.length" class="row roulette-demo-layout">
<aside class="col-md-3 col-sm-12 roulette-pending-sidebar text-left">
<div class="panel panel-default roulette-pending-panel">
<div class="panel-heading clearfix">
<span>Menunggu sertai cabutan</span>
<span class="badge pull-right">{{ pendingVoters.length }}</span>
</div>
<ul class="list-group roulette-pending-list">
<li v-if="!pendingVoters.length" class="list-group-item text-muted roulette-pending-empty">
Tiada dalam senarai menunggu semua peserta telah diimport atau senarai kosong.
</li>
<template v-else>
<li v-for="v in pendingVoters" :key="'p-' + v.id"
class="list-group-item roulette-pending-row">
<span class="roulette-pending-name">{{ labelForVoter(v) }}</span>
<button type="button" class="btn btn-primary btn-xs roulette-pending-import"
@click="importVoter(v.id)">
Import
</button>
</li>
</template>
</ul>
<div v-if="pendingVoters.length > 1" class="panel-footer text-right">
<button type="button" class="btn btn-default btn-sm" @click="importAllPending">
Import semua
</button>
</div>
</div>
</aside>
<div class="col-md-9 col-sm-12 roulette-main-column">
<div v-if="cabutanCount < 2" class="alert alert-info roulette-cabutan-hint">
Import sekurang-kurangnya <strong>dua</strong> peserta daripada senarai menunggu untuk memulakan
cabutan. Senarai <strong>Peserta</strong> pada roda hanya berubah apabila anda menambah peserta di
sini; auto sync mengemas kini senarai menunggu sahaja.
</div>
<roulette-elimination-wheel v-if="cabutanCount >= 2" :items="cabutanItems" :full-width="true"
persist-key="roulette_fizikal_demo" @winner="onWinner" />
</div>
</div>
</div>
</template>
<script>
import RouletteEliminationWheel from './RouletteEliminationWheel.vue';
import Logo from '../common/Logo.vue';
function buildLabelsFromVoters(voters) {
if (!voters || !voters.length) {
return [];
}
var nameCount = {};
for (var i = 0; i < voters.length; i++) {
var raw = voters[i].name;
var n = (raw != null ? String(raw) : '').trim();
nameCount[n] = (nameCount[n] || 0) + 1;
}
return voters.map(function (v) {
var n = (v.name != null ? String(v.name) : '').trim();
if (!n) {
n = '#' + v.id;
}
if (nameCount[n] > 1) {
var tag = v.no_anggota != null ? String(v.no_anggota).trim() : '';
return tag ? n + ' (' + tag + ')' : n + ' (#' + v.id + ')';
}
return n;
});
}
/** Persist which voter ids were added to the cabutan (separate from wheel game state). */
var IMPORTED_IDS_STORAGE_KEY = 'roulette_fizikal_imported_ids';
/** Looping background track (`public/audio/…`). */
var BG_MUSIC_SRC = '/audio/lucky-draw-bg.mp3';
/** Stable string so we detect new/removed/changed voters from API. */
function votersSnapshotSignature(voters) {
if (!voters || !voters.length) {
return 'empty';
}
var rows = voters.map(function (v) {
return (
String(v.id) +
'\t' +
String(v.name != null ? v.name : '') +
'\t' +
String(v.no_anggota != null ? v.no_anggota : '')
);
});
rows.sort();
return rows.join('|');
}
export default {
name: 'RouletteEliminationWheelDemo',
components: {
RouletteEliminationWheel,
Logo,
},
data: function () {
return {
loading: true,
loadError: '',
/** Full list from GET …/fizikal-voters (updated by auto sync / manual refresh). */
apiVoters: [],
/** Voter ids explicitly imported into the cabutan / wheel; order = cabutan order. */
importedParticipantIds: [],
/** Last applied API snapshot — skip redundant Vue updates when unchanged. */
lastSnapshotSig: '',
refreshing: false,
lastSyncedAt: null,
pollError: '',
hasLoadedOnce: false,
pollTimerId: null,
pollStarted: false,
/** Interval in ms; set 0 to disable auto-refresh. */
pollIntervalMs: 10000,
/** True while background music is playing (user must click Main first — browser policy). */
bgMusicPlaying: false,
};
},
computed: {
lastSyncedFormatted: function () {
if (!this.lastSyncedAt) {
return '—';
}
try {
return this.lastSyncedAt.toLocaleString();
} catch (e) {
return String(this.lastSyncedAt);
}
},
/** Label string per voter id (duplicate-name disambiguation uses full API list). */
voterLabelsById: function () {
var list = this.apiVoters;
var labels = buildLabelsFromVoters(list);
var map = {};
for (var i = 0; i < list.length; i++) {
var vid = list[i].id;
var nid = typeof vid === 'number' && vid === vid ? vid : parseInt(vid, 10);
if (nid === nid) {
map[nid] = labels[i];
}
}
return map;
},
pendingVoters: function () {
var imported = this.importedParticipantIds;
return this.apiVoters.filter(function (v) {
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
return imported.indexOf(vid) < 0;
});
},
/** Wheel entries: stable voter id + label (order = import order). */
cabutanItems: function () {
var map = this.voterLabelsById;
var out = [];
for (var i = 0; i < this.importedParticipantIds.length; i++) {
var id = this.importedParticipantIds[i];
if (map[id] !== undefined) {
out.push({ id: id, label: map[id] });
}
}
return out;
},
cabutanCount: function () {
return this.cabutanItems.length;
},
},
created: function () {
this.loadImportedIdsFromStorage();
this.fetchFizikalVoters(false);
},
beforeDestroy: function () {
this.stopPolling();
this.disposeBgMusic();
},
methods: {
disposeBgMusic: function () {
this.bgMusicPlaying = false;
if (!this._bgMusicAudio) {
return;
}
try {
this._bgMusicAudio.pause();
this._bgMusicAudio.src = '';
} catch (e) {
/* ignore */
}
this._bgMusicAudio = null;
},
ensureBgMusicAudio: function () {
if (typeof Audio === 'undefined') {
return null;
}
if (!this._bgMusicAudio) {
this._bgMusicAudio = new Audio(BG_MUSIC_SRC);
this._bgMusicAudio.loop = true;
this._bgMusicAudio.volume = 0.32;
this._bgMusicAudio.preload = 'auto';
}
return this._bgMusicAudio;
},
toggleBgMusic: function () {
var vm = this;
var a = this.ensureBgMusicAudio();
if (!a) {
return;
}
if (this.bgMusicPlaying) {
a.pause();
this.bgMusicPlaying = false;
return;
}
var p = a.play();
if (p && typeof p.then === 'function') {
p.then(function () {
vm.bgMusicPlaying = true;
}).catch(function () {
vm.bgMusicPlaying = false;
});
} else {
this.bgMusicPlaying = true;
}
},
loadImportedIdsFromStorage: function () {
if (typeof localStorage === 'undefined') {
return;
}
try {
var raw = localStorage.getItem(IMPORTED_IDS_STORAGE_KEY);
if (!raw) {
return;
}
var parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return;
}
this.importedParticipantIds = parsed.filter(function (x) {
return typeof x === 'number' && x === x;
});
} catch (e) {
this.importedParticipantIds = [];
}
},
saveImportedIdsToStorage: function () {
if (typeof localStorage === 'undefined') {
return;
}
try {
localStorage.setItem(
IMPORTED_IDS_STORAGE_KEY,
JSON.stringify(this.importedParticipantIds)
);
} catch (e) {
// quota / private mode
}
},
/** Drop imported ids that no longer exist in the latest API list. */
pruneImportedIdsToApi: function (list) {
var ok = {};
for (var i = 0; i < list.length; i++) {
var vid = list[i].id;
var nid = typeof vid === 'number' && vid === vid ? vid : parseInt(vid, 10);
if (nid === nid) {
ok[nid] = true;
}
}
var next = this.importedParticipantIds.filter(function (id) {
return ok[id];
});
if (next.length !== this.importedParticipantIds.length) {
this.importedParticipantIds = next;
this.saveImportedIdsToStorage();
}
},
labelForVoter: function (v) {
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
var m = this.voterLabelsById;
return vid === vid && m[vid] != null ? m[vid] : '#' + v.id;
},
importVoter: function (id) {
var nid = typeof id === 'number' && id === id ? id : parseInt(id, 10);
if (nid !== nid) {
return;
}
if (this.importedParticipantIds.indexOf(nid) >= 0) {
return;
}
this.importedParticipantIds.push(nid);
this.saveImportedIdsToStorage();
},
importAllPending: function () {
var vm = this;
this.pendingVoters.forEach(function (v) {
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
if (vid === vid && vm.importedParticipantIds.indexOf(vid) < 0) {
vm.importedParticipantIds.push(vid);
}
});
this.saveImportedIdsToStorage();
},
stopPolling: function () {
if (this.pollTimerId !== null) {
clearInterval(this.pollTimerId);
this.pollTimerId = null;
}
this.pollStarted = false;
},
startPolling: function () {
if (this.pollIntervalMs <= 0) {
return;
}
if (this.pollStarted) {
return;
}
this.pollStarted = true;
var vm = this;
this.pollTimerId = setInterval(function () {
vm.fetchFizikalVoters(true);
}, this.pollIntervalMs);
},
/**
* @param {boolean} silent - true = background poll (no full-page loading state).
*/
fetchFizikalVoters: function (silent) {
var vm = this;
if (!silent) {
vm.loadError = '';
vm.pollError = '';
if (!vm.hasLoadedOnce) {
vm.loading = true;
} else {
vm.refreshing = true;
}
} else {
vm.pollError = '';
vm.refreshing = true;
}
return axios
.get(config.API + 'public/roulette/fizikal-voters')
.then(function (response) {
var list =
response.data && response.data.voters ? response.data.voters : [];
var sig = votersSnapshotSignature(list);
if (silent && sig === vm.lastSnapshotSig) {
vm.lastSyncedAt = new Date();
return;
}
vm.lastSnapshotSig = sig;
vm.apiVoters = list;
vm.pruneImportedIdsToApi(list);
vm.lastSyncedAt = new Date();
vm.hasLoadedOnce = true;
})
.catch(function (error) {
var msg =
error &&
error.response &&
error.response.data &&
error.response.data.message
? error.response.data.message
: 'Gagal memuat data.';
if (!silent && !vm.hasLoadedOnce) {
vm.loadError = msg;
vm.apiVoters = [];
} else {
vm.pollError =
'Tidak dapat mengemas kini: ' + (msg.length > 80 ? msg.slice(0, 80) + '…' : msg);
}
})
.finally(function () {
vm.loading = false;
vm.refreshing = false;
vm.startPolling();
});
},
onWinner: function (payload) {
if (typeof console !== 'undefined' && console.log) {
console.log('[roulette demo] winner:', payload);
}
},
},
};
</script>
<style scoped>
.roulette-demo-page {
padding-top: 2rem;
padding-bottom: 3rem;
width: 100%;
max-width: none;
}
.roulette-page-header {
display: flex;
justify-content: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e7e7e7;
}
.roulette-page-header-inner {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 14px 18px;
text-align: left;
}
.roulette-page-header-logo {
display: block;
height: 52px;
width: auto;
max-width: 140px;
object-fit: contain;
flex-shrink: 0;
}
.roulette-demo-title {
font-size: 1.65rem;
font-weight: 600;
margin: 0;
line-height: 1.3;
max-width: min(100%, 36rem);
}
.roulette-bg-music-btn {
flex-shrink: 0;
white-space: nowrap;
}
.roulette-demo-path {
margin-bottom: 1.25rem;
font-size: 0.9rem;
word-break: break-all;
}
.roulette-poll-help {
margin-bottom: 0.75rem;
line-height: 1.45;
}
.roulette-demo-loading {
margin-bottom: 1rem;
}
.roulette-sync-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
margin-bottom: 14px;
font-size: 13px;
}
.roulette-sync-refreshing {
font-style: italic;
}
.roulette-sync-poll-error {
font-size: 12px;
}
.roulette-sync-btn {
flex-shrink: 0;
}
.roulette-demo-layout {
align-items: flex-start;
}
.roulette-pending-sidebar {
margin-bottom: 16px;
}
@media (min-width: 992px) {
.roulette-pending-sidebar {
margin-bottom: 0;
}
.roulette-pending-panel {
position: sticky;
top: 12px;
}
}
.roulette-main-column {
min-width: 0;
}
.roulette-pending-panel {
margin-bottom: 0;
}
.roulette-pending-list {
margin-bottom: 0;
}
.roulette-pending-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 13px;
}
.roulette-pending-name {
flex: 1;
min-width: 0;
word-break: break-word;
}
.roulette-pending-import {
flex-shrink: 0;
}
.roulette-cabutan-hint {
margin-bottom: 16px;
line-height: 1.45;
}
</style>