Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/php-sandbox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Build & deploy sandbox

on:
push:
branches: [ "develop"]
branches: [ "develop", "add-stream-live-view"]
workflow_dispatch:

permissions:
Expand Down
15 changes: 15 additions & 0 deletions migrations/20-add-stream-live-view.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
ALTER TABLE {prefix}charity_stream
ADD COLUMN live_started_at DATETIME(6) NULL,
ADD COLUMN live_continuation_token VARCHAR(255) NULL,
ADD COLUMN live_cache_updated_at DATETIME(6) NULL;

CREATE TABLE {prefix}stream_donation_history (
id INT AUTO_INCREMENT PRIMARY KEY,
charity_stream_guid CHAR(32) NOT NULL,
pseudo VARCHAR(255) NOT NULL,
message TEXT NULL,
amount INT NOT NULL,
received_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
CONSTRAINT fk_{prefix}charity_stream_guid_donation_history FOREIGN KEY (charity_stream_guid) REFERENCES {prefix}charity_stream(guid),
INDEX idx_stream_received (charity_stream_guid, id)
);
6 changes: 6 additions & 0 deletions public/dist/.vite/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@
"src/Assets/js/utilities.js"
]
},
"src/Assets/js/live.js": {
"file": "live.min.js",
"name": "live",
"src": "src/Assets/js/live.js",
"isEntry": true
},
"src/Assets/js/stream.js": {
"file": "stream.min.js",
"name": "stream",
Expand Down
1 change: 1 addition & 0 deletions public/dist/live.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use App\Repositories\EventRepository;
use App\Repositories\FileManager;
use App\Repositories\GoalRepository;
use App\Repositories\StreamDonationHistoryRepository;
use App\Repositories\StreamRepository;
use App\Repositories\UserRepository;
use App\Repositories\WidgetRepository;
Expand Down Expand Up @@ -83,6 +84,10 @@
return new StreamRepository($c->get(PDO::class), $_SERVER['DBPREFIX']);
});

$container->set(StreamDonationHistoryRepository::class, function ($c) {
return new StreamDonationHistoryRepository($c->get(PDO::class), $_SERVER['DBPREFIX']);
});

$container->set(UserRepository::class, function ($c) {
return new UserRepository($c->get(PDO::class), $_SERVER['DBPREFIX']);
});
Expand Down Expand Up @@ -226,6 +231,11 @@ function (\Psr\Http\Message\ServerRequestInterface $request, \Throwable $excepti
$app->post('/admin/stream/{id}/delete', [AdminController::class, 'deleteStream'])->add(new AuthMiddleware())->setName('app_stream_delete');
$app->get('/admin/stream/{id}/edit', [AdminController::class, 'editStream'])->add(new AuthMiddleware())->setName('app_stream_edit');
$app->post('/admin/stream/{id}/edit', [AdminController::class, 'editStreamPost'])->add(new AuthMiddleware())->setName('app_stream_edit_post');
$app->get('/admin/stream/{id}/live', [AdminController::class, 'liveView'])->add(new AuthMiddleware())->setName('app_stream_live');
$app->post('/admin/stream/{id}/live/start', [AdminController::class, 'startLive'])->add(new AuthMiddleware())->setName('app_stream_live_start');
$app->post('/admin/stream/{id}/live/stop', [AdminController::class, 'stopLive'])->add(new AuthMiddleware())->setName('app_stream_live_stop');
$app->get('/admin/stream/{id}/live/fetch', [AdminController::class, 'liveFetch'])->add(new AuthMiddleware())->setName('app_stream_live_fetch');
$app->get('/admin/stream/{id}/live/history', [AdminController::class, 'liveHistory'])->add(new AuthMiddleware())->setName('app_stream_live_history');

$app->post('/api/stream', [ApiController::class, 'new'])->add(new AuthApiMiddleware())->setName('app_api_stream_new');

Expand Down
172 changes: 172 additions & 0 deletions src/Assets/js/live.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Vue "Live" : polling des nouveaux dons + scroll infini vers l'historique.
// Tout le contenu venant des donateurs (pseudo, message) est injecté via
// textContent, jamais innerHTML, pour éviter toute exécution de HTML/JS.

const feed = document.getElementById('donationFeed');
const sentinel = document.getElementById('feedSentinel');
const emptyState = document.getElementById('feedEmptyState');
const endNotice = document.getElementById('feedEndNotice');
const capNotice = document.getElementById('feedCapNotice');

const PAGE_SIZE = 30;
// Cap du nombre de lignes rendues : au-delà, on arrête de charger plus
// d'historique pour ne pas faire exploser le DOM sur un long live
const MAX_RENDERED_ROWS = 300;

let isFetching = false;
let isLoadingOlder = false;
let newestId = window.newestLoadedId || 0;
let oldestId = window.oldestLoadedId || 0;
let renderedCount = window.initialCount || 0;
let reachedEnd = (window.initialCount || 0) < PAGE_SIZE;

function formatAmount(cents) {
return (cents / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
}

function formatDate(receivedAt) {
// MySQL DATETIME "YYYY-MM-DD HH:MM:SS.uuuuuu" → parsable en remplaçant l'espace
const date = new Date(String(receivedAt).replace(' ', 'T'));
return isNaN(date.getTime()) ? String(receivedAt) : date.toLocaleString('fr-FR');
}

function buildEntry(entry) {
const card = document.createElement('div');
card.className = 'live-entry card mb-2';
card.dataset.id = entry.id;

const body = document.createElement('div');
body.className = 'card-body py-2 px-3';

const meta = document.createElement('div');
meta.className = 'd-flex justify-content-between align-items-baseline gap-2';

const pseudo = document.createElement('span');
pseudo.className = 'fw-bold';
pseudo.textContent = entry.pseudo;

const amount = document.createElement('span');
amount.className = 'badge bg-success';
amount.style.fontSize = '0.9rem';
amount.textContent = formatAmount(entry.amount);

const time = document.createElement('span');
time.className = 'text-muted small ms-auto';
time.textContent = formatDate(entry.received_at);

meta.append(pseudo, amount, time);
body.append(meta);

if (entry.message) {
const message = document.createElement('p');
message.className = 'mb-0 mt-1';
message.style.opacity = '0.85';
message.textContent = entry.message;
body.append(message);
}

card.append(body);
return card;
}

async function fetchNewDonations() {
if (isFetching) {
return;
}
isFetching = true;

try {
const response = await fetch(`/admin/stream/${window.charityStreamId}/live/fetch?since_id=${newestId}`);
if (!response.ok) {
console.error('Erreur lors de la récupération des dons du live :', response.status);
return;
}

const entries = await response.json();
if (!Array.isArray(entries) || entries.length === 0) {
return;
}

if (emptyState) {
emptyState.style.display = 'none';
}

// Le serveur renvoie les dons par id croissant : en préprendant chacun
// dans l'ordre, le plus récent finit tout en haut du fil
for (const entry of entries) {
feed.prepend(buildEntry(entry));
newestId = Math.max(newestId, entry.id);
if (oldestId === 0) {
oldestId = entry.id;
}
renderedCount++;
}
} catch (error) {
console.error('Erreur réseau lors de la récupération des dons du live :', error);
} finally {
isFetching = false;
}
}

async function fetchOlderDonations() {
if (isLoadingOlder || reachedEnd || oldestId === 0) {
return;
}

if (renderedCount >= MAX_RENDERED_ROWS) {
observer.disconnect();
if (capNotice) {
capNotice.style.display = 'block';
}
return;
}

isLoadingOlder = true;

try {
const response = await fetch(`/admin/stream/${window.charityStreamId}/live/history?before_id=${oldestId}`);
if (!response.ok) {
console.error('Erreur lors du chargement de l\'historique du live :', response.status);
return;
}

const entries = await response.json();
if (!Array.isArray(entries)) {
return;
}

// Les pages arrivent par id décroissant : on les ajoute à la suite du fil
for (const entry of entries) {
feed.insertBefore(buildEntry(entry), sentinel);
oldestId = entry.id;
renderedCount++;
}

if (entries.length < PAGE_SIZE) {
reachedEnd = true;
observer.disconnect();
if (endNotice && renderedCount > 0) {
endNotice.style.display = 'block';
}
}
} catch (error) {
console.error('Erreur réseau lors du chargement de l\'historique du live :', error);
} finally {
isLoadingOlder = false;
}
}

const observer = new IntersectionObserver((observedEntries) => {
if (observedEntries.some((observed) => observed.isIntersecting)) {
fetchOlderDonations();
}
});

if (feed && window.liveStarted) {
fetchNewDonations();
setInterval(fetchNewDonations, 10000);

if (sentinel && !reachedEnd) {
observer.observe(sentinel);
}
}
Loading
Loading