Amélioration avec des fonctions d'un vrai dashboard automotive et containerisation du projet pour faciliter le déploiement
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
import { applyTheme, accents } from './theme.js';
|
||||
import { initJourney } from './journey.js';
|
||||
import { initCockpit } from './cockpit.js';
|
||||
import { initEmbeds } from './embeds.js';
|
||||
import { initApplications, applications } from './applications.js';
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const all = selector => document.querySelectorAll(selector);
|
||||
const storage = {
|
||||
@@ -6,14 +11,16 @@ const storage = {
|
||||
};
|
||||
function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
||||
function navigate() {
|
||||
const view = ['home', 'drive', 'music', 'stream'].includes(location.hash.slice(1)) ? location.hash.slice(1) : 'home';
|
||||
const titles = { home: 'Accueil', drive: 'Conduite', music: 'Musique', stream: 'Jellyfin', map: 'Navigation', apps: 'Applications', 'vehicle-details': 'Détails véhicule', 'now-playing': 'Lecture', embedded: 'Lecteur intégré' };
|
||||
const view = Object.hasOwn(titles, location.hash.slice(1)) ? location.hash.slice(1) : 'home';
|
||||
all('.view').forEach(section => section.hidden = section.id !== view);
|
||||
all('[data-view]').forEach(link => {
|
||||
const active = link.dataset.view === view;
|
||||
link.classList.toggle('active', active);
|
||||
if (active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current');
|
||||
});
|
||||
document.title = `CARIA · ${$(`[data-view="${view}"]`).textContent.trim()}`;
|
||||
document.title = `CARIA · ${titles[view]}`;
|
||||
document.dispatchEvent(new CustomEvent('caria-view', { detail: view }));
|
||||
}
|
||||
window.addEventListener('hashchange', navigate);
|
||||
navigate();
|
||||
@@ -35,14 +42,35 @@ function makeGauge(id, max, step, unit, label) {
|
||||
for (let v = 0; v <= max; v += step / 4) {
|
||||
const angle = -130 + v / max * 260;
|
||||
const major = v % step === 0;
|
||||
const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 97);
|
||||
ticks += `<line class="tick ${major ? 'major' : ''}" x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/>`;
|
||||
if (major) ticks += `<text x="${n[0]}" y="${n[1] + 4}">${max === 7000 ? v / 1000 : v}</text>`;
|
||||
const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 96);
|
||||
ticks += `<line class="tick ${major ? 'major' : v % (step / 2) === 0 ? 'intermediate' : 'quarter'}" x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/>`;
|
||||
if (major) ticks += `<text class="dial-number" x="${n[0]}" y="${n[1] + 4}">${max === 7000 ? v / 1000 : v}</text>`;
|
||||
}
|
||||
$(id).innerHTML = `<svg viewBox="0 0 300 285" role="img" aria-label="${label} : en attente"><circle cx="150" cy="150" r="133" fill="none" stroke="var(--border)"/>${ticks}<text x="150" y="110" class="dial-label">${unit}</text><line class="needle" x1="150" y1="150" x2="150" y2="43" style="transform-origin:150px 150px;transform:rotate(-130deg)"/><circle cx="150" cy="150" r="7" fill="var(--accent)"/><circle cx="150" cy="150" r="3" fill="var(--surface)"/><text x="150" y="212" class="dial-value">—</text><text x="150" y="239" class="dial-label">${label}</text></svg>`;
|
||||
// Decorative demonstration red zone; not a calibrated vehicle redline.
|
||||
const redStart = point(-130 + 5500 / 7000 * 260, 128), redEnd = point(130, 128);
|
||||
const redZone = max === 7000 ? `<defs><linearGradient id="rpm-red-zone" gradientUnits="userSpaceOnUse" x1="${redStart[0]}" y1="${redStart[1]}" x2="${redEnd[0]}" y2="${redEnd[1]}"><stop stop-color="#f7a65d" stop-opacity="0.1"/><stop offset="0.5" stop-color="#f67855"/><stop offset="1" stop-color="#fa4d5b"/></linearGradient></defs><path class="rpm-red-zone" d="M${redStart.join(' ')} A128 128 0 0 1 ${redEnd.join(' ')}"/>` : '';
|
||||
const levelKey = max === 7000 ? 'coolantCelsius' : 'fuelPercent';
|
||||
const iconPath = document.querySelector(`[data-level="${levelKey}"] .aux-title svg`).innerHTML;
|
||||
// Compact auxiliary arc, separated from the main instrument.
|
||||
const levelPoint = (angle, radius = 64) => [150+radius*Math.sin(angle*Math.PI/180),244-radius*Math.cos(angle*Math.PI/180)];
|
||||
// 86.8 degrees: 30% shorter than the previous 124-degree auxiliary arc.
|
||||
const arcPath = (from,to) => `M${levelPoint(from).join(' ')} A64 64 0 0 0 ${levelPoint(to).join(' ')}`;
|
||||
const arc = arcPath(223.4,136.6);
|
||||
const redArc = max===7000 ? arcPath(153.96,136.6) : arcPath(223.4,212.984);
|
||||
const tickRatios = max === 7000 ? [0,.28,.56,.84,1] : [0,.12,.4133333333,.7066666667,1];
|
||||
const levelTicks = tickRatios.map((fraction,index) => {
|
||||
const angle = 223.4 - 86.8*fraction;
|
||||
const inner=levelPoint(angle,54), outer=levelPoint(angle,61);
|
||||
return `<path class="level-tick" d="M${inner.join(' ')} L${outer.join(' ')}"/>`;
|
||||
}).join('');
|
||||
const level = `<g class="digital-levels" data-digital-level="${levelKey}"><path class="level-arc-track" d="${arc}"/><path class="level-arc-progress" pathLength="100" d="${arc}"/><path class="level-arc-red" d="${redArc}"/>${levelTicks}<text class="level-end" x="95" y="304">${max===7000?'C':'E'}</text><text class="level-end" x="205" y="304">${max===7000?'H':'F'}</text>${max===7000?'':'<text class="level-end" x="150" y="325">½</text>'}<g class="level-icon" transform="translate(${max===7000?139.8:141.6} ${max===7000?267.8:269.6}) scale(${max===7000?.85:.7})" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${iconPath}</g><text class="level-value" x="150" y="348"><tspan data-value="${levelKey}">—</tspan> ${max===7000?'°C':'%'}</text></g>`;
|
||||
const low = max === 7000 ? 'C' : 'E', high = max === 7000 ? 'H' : 'F';
|
||||
const mini = `<path class="analog-separator" d="M59.590 240.609 L77 226 L223 226 L240.410 240.609"/><g class="analog-levels" data-analog-level="${levelKey}"><g transform="translate(150 264) scale(0.90) translate(-150 -264)"><g transform="translate(0 572) scale(1 -1)"><path class="mini-track" d="M90 308 A60 60 0 0 1 210 308"/><path class="mini-red" d="${max===7000?'M192.4 265.6 A60 60 0 0 1 210 308':'M90 308 A60 60 0 0 1 107.6 265.6'}"/><path class="mini-ticks" d="M90 308h8M107.6 265.6l6 6M150 248v8M192.4 265.6l-6 6M210 308h-8"/><text class="mini-end" transform="translate(0 604) scale(1 -1)" x="104" y="302">${low}</text><text class="mini-end" transform="translate(0 604) scale(1 -1)" x="196" y="302">${high}</text>${max===7000?'':`<text class="mini-end" transform="translate(0 520) scale(1 -1)" x="150" y="260">½</text>`}<line class="mini-needle" x1="150" y1="308" x2="150" y2="258"/><circle class="mini-hub" cx="150" cy="308" r="3"/></g></g><g class="level-icon" transform="translate(${max===7000?139.8:141.6} ${max===7000?277.8:279.6}) scale(${max===7000?.85:.7})" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${iconPath}</g><text class="level-value" x="150" y="368"><tspan data-value="${levelKey}">—</tspan> ${max===7000?'°C':'%'}</text></g>`;
|
||||
$(id).innerHTML = `<svg viewBox="0 0 300 265" role="img" aria-label="${label} : en attente"><path class="dial-rim" d="M68.4 255 A133 133 0 1 1 231.6 255 Z" fill="none" stroke="var(--border)"/><path class="dial-light" d="M51.9 232.3 A128 128 0 1 1 248.1 232.3"/><path class="digital-track" d="M51.9 232.3 A128 128 0 1 1 248.1 232.3"/><path class="digital-progress" pathLength="100" d="M51.9 232.3 A128 128 0 1 1 248.1 232.3"/>${ticks}${redZone}<text x="150" y="110" class="dial-label analog-unit">${unit}</text><text x="150" y="110" class="dial-label digital-unit">${max === 7000 ? 'TR/MIN' : unit}</text><line class="needle" x1="150" y1="150" x2="150" y2="43" style="transform-origin:150px 150px;transform:rotate(-130deg)"/><circle class="needle-hub" cx="150" cy="150" r="7" fill="var(--accent)"/><circle class="needle-hub" cx="150" cy="150" r="3" fill="var(--surface)"/><text x="150" y="212" class="dial-value">—</text><text x="150" y="239" class="dial-label">${label}</text>${level}${mini}</svg>`;
|
||||
return value => {
|
||||
const valid = typeof value === 'number' && Number.isFinite(value);
|
||||
$(`${id} .dial-value`).textContent = valid ? Math.round(value).toLocaleString('fr-FR') : '—';
|
||||
$(`${id} .digital-progress`).style.strokeDashoffset = String(valid ? 100 - Math.max(0,Math.min(1,value/max))*100 : 100);
|
||||
$(`${id} .needle`).style.opacity = valid ? '1' : '0';
|
||||
if (valid) $(`${id} .needle`).style.transform = `rotate(${-130 + Math.max(0, Math.min(max, value)) / max * 260}deg)`;
|
||||
$(`${id} svg`).setAttribute('aria-label', `${label} : ${valid ? Math.round(value) : 'indisponible'} ${max === 7000 ? 'tr/min' : 'km/h'}`);
|
||||
@@ -53,6 +81,37 @@ const rpmGauge = makeGauge('#rpm-gauge', 7000, 1000, '× 1 000 TR/MIN', 'RÉGIME
|
||||
const savedProfile = storage.get('profile', 'route');
|
||||
$('#profile').value = ['route', 'ville', 'arret'].includes(savedProfile) ? savedProfile : 'route';
|
||||
let generation = 0, timer;
|
||||
function updateLevels(data) {
|
||||
all('[data-level]').forEach(el => {
|
||||
const key = el.dataset.level, value = data?.[key], valid = Number.isFinite(value);
|
||||
const min = Number(el.getAttribute('aria-valuemin')), max = Number(el.getAttribute('aria-valuemax'));
|
||||
const needle = el.querySelector('.aux-needle');
|
||||
if (needle) { needle.style.transform = `rotate(${valid ? -90+Math.max(0,Math.min(1,(value-min)/(max-min)))*180 : -90}deg)`; needle.style.opacity = valid ? '1' : '0'; }
|
||||
// Demo visualization ranges; actual CAN calibration belongs to the vehicle profile.
|
||||
const ratio = valid ? Math.max(0,Math.min(1,(value-min)/(max-min))) : 0;
|
||||
const starts = key === 'fuelPercent' ? [0,.1,.4,.7] : [0,.25,.5,.75];
|
||||
el.querySelectorAll('.sensor-segments i').forEach((segment,i) => {
|
||||
segment.classList.toggle('filled', valid && ratio > starts[i]);
|
||||
});
|
||||
const analog = document.querySelector(`[data-analog-level="${key}"]`);
|
||||
analog.querySelector('.mini-needle').style.transform = `rotate(${-90 + ratio*180}deg)`;
|
||||
analog.querySelector('.mini-needle').style.opacity = valid ? '1' : '0';
|
||||
analog.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
|
||||
const digital = document.querySelector(`[data-digital-level="${key}"]`);
|
||||
const stops = key === 'fuelPercent' ? [0,.12,.4133333333,.7066666667,1] : [0,.28,.56,.84,1];
|
||||
const stepped = stops.filter(stop => stop <= ratio).at(-1) ?? 0;
|
||||
const progress = digital.querySelector('.level-arc-progress');
|
||||
progress.style.strokeDashoffset = String(100*(1-stepped));
|
||||
progress.style.opacity = valid && stepped > 0 ? '1' : '0';
|
||||
digital.classList.toggle('last-level', valid && key === 'coolantCelsius' && ratio >= .84);
|
||||
digital.classList.toggle('reserve-level', valid && key === 'fuelPercent' && stepped === .12);
|
||||
digital.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
|
||||
el.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
|
||||
el.classList.toggle('reserve', valid && key === 'fuelPercent' && value <= 10);
|
||||
if (valid) el.setAttribute('aria-valuenow', String(value)); else el.removeAttribute('aria-valuenow');
|
||||
el.setAttribute('aria-valuetext', valid ? value + (key === 'fuelPercent' ? ' %' : ' °C') : 'Indisponible');
|
||||
});
|
||||
}
|
||||
function offline() {
|
||||
$('#connection').textContent = 'Données indisponibles';
|
||||
$('#connection').classList.add('error');
|
||||
@@ -60,6 +119,7 @@ function offline() {
|
||||
document.body.classList.add('stale');
|
||||
all('[data-value]').forEach(el => el.textContent = '—');
|
||||
all('[data-fuel]').forEach(el => el.style.width = '0%');
|
||||
updateLevels(null);
|
||||
speedGauge(null); rpmGauge(null);
|
||||
}
|
||||
async function poll() {
|
||||
@@ -81,6 +141,7 @@ async function poll() {
|
||||
el.textContent = typeof value === 'number' ? value.toLocaleString('fr-FR', { maximumFractionDigits: 1 }) : value;
|
||||
});
|
||||
all('[data-fuel]').forEach(el => el.style.width = `${Math.max(0, Math.min(100, data.fuelPercent))}%`);
|
||||
updateLevels(data);
|
||||
speedGauge(data.speedKph); rpmGauge(data.rpm);
|
||||
} catch { if (current === generation) offline(); }
|
||||
finally { if (current === generation) timer = setTimeout(poll, 500); }
|
||||
@@ -88,41 +149,34 @@ async function poll() {
|
||||
$('#profile').addEventListener('change', () => { storage.set('profile', $('#profile').value); poll(); });
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === 'caria.profile' && ['route', 'ville', 'arret'].includes(event.newValue)) { $('#profile').value = event.newValue; poll(); }
|
||||
if (event.key === 'caria.stream') configureStream();
|
||||
if (event.key === 'caria.theme') document.body.classList.toggle('light', event.newValue === 'light');
|
||||
if (['caria.theme', 'caria.accent', 'caria.customColor', 'caria.dialStyle'].includes(event.key)) applyTheme(storage);
|
||||
});
|
||||
poll();
|
||||
|
||||
function validUrl(value) {
|
||||
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; } catch { return ''; }
|
||||
}
|
||||
const defaultStream = 'https://media.cunatbrule.fr/web/';
|
||||
function configureStream() {
|
||||
const url = validUrl(storage.get('stream', defaultStream));
|
||||
$('#stream-link').hidden = !url;
|
||||
if (url) $('#stream-link').href = url; else $('#stream-link').removeAttribute('href');
|
||||
$('#stream-description').textContent = url ? new URL(url).hostname : 'Ajoutez l’adresse de votre site dans les réglages.';
|
||||
$('#stream-configure').textContent = url ? 'Modifier le site' : 'Configurer mon site';
|
||||
}
|
||||
configureStream();
|
||||
document.body.classList.toggle('light', storage.get('theme') === 'light');
|
||||
const jellyfinUrl = applications.find(app => app.id === 'stream').url;
|
||||
$('#stream-link').hidden = false;
|
||||
$('#stream-link').href = jellyfinUrl;
|
||||
$('#stream-description').textContent = new URL(jellyfinUrl).hostname;
|
||||
applyTheme(storage);
|
||||
function openSettings() {
|
||||
$('#stream-url').value = storage.get('stream', defaultStream);
|
||||
$('#theme').value = document.body.classList.contains('light') ? 'light' : 'dark';
|
||||
$('#accent').value = storage.get('accent', 'green');
|
||||
$('#dial-style').value = ['digital', 'luminous'].includes(storage.get('dialStyle')) ? storage.get('dialStyle') : 'analog';
|
||||
$('#custom-color').value = storage.get('customColor', accents.green);
|
||||
$('#settings-message').textContent = '';
|
||||
$('#settings').showModal();
|
||||
}
|
||||
$('#custom-color').addEventListener('input', () => { $('#accent').value = 'custom'; });
|
||||
$('#settings-open').onclick = openSettings;
|
||||
$('#stream-configure').onclick = openSettings;
|
||||
$('#settings-close').onclick = () => $('#settings').close();
|
||||
$('#settings-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const raw = $('#stream-url').value.trim(), url = validUrl(raw);
|
||||
if (raw && !url) { $('#settings-message').textContent = 'Saisissez une adresse HTTP ou HTTPS, sans identifiant ni mot de passe.'; return; }
|
||||
const streamSaved = storage.set('stream', url), themeSaved = storage.set('theme', $('#theme').value);
|
||||
if (!streamSaved || !themeSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
|
||||
document.body.classList.toggle('light', $('#theme').value === 'light');
|
||||
configureStream(); $('#settings').close(); toast('Réglages enregistrés');
|
||||
const dialSaved = storage.set('dialStyle', $('#dial-style').value);
|
||||
const themeSaved = storage.set('theme', $('#theme').value);
|
||||
const accentSaved = storage.set('accent', $('#accent').value), colorSaved = storage.set('customColor', $('#custom-color').value);
|
||||
if (!dialSaved || !themeSaved || !accentSaved || !colorSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
|
||||
applyTheme(storage);
|
||||
$('#settings').close(); toast('Réglages enregistrés');
|
||||
});
|
||||
$('#fullscreen').onclick = async () => {
|
||||
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
|
||||
@@ -131,6 +185,25 @@ $('#fullscreen').onclick = async () => {
|
||||
|
||||
const audio = $('#audio');
|
||||
let tracks = [], selected = -1;
|
||||
let shuffle = storage.get('shuffle') === 'true', shuffleBag = [], playbackHistory = [];
|
||||
$('#immersive-shuffle').setAttribute('aria-pressed', String(shuffle));
|
||||
$('#immersive-shuffle').onclick = () => {
|
||||
shuffle = !shuffle; shuffleBag = []; playbackHistory = [];
|
||||
storage.set('shuffle', String(shuffle));
|
||||
$('#immersive-shuffle').setAttribute('aria-pressed', String(shuffle));
|
||||
toast(shuffle ? 'Lecture aléatoire activée' : 'Lecture aléatoire désactivée');
|
||||
};
|
||||
function nextTrack() {
|
||||
if (!shuffle || tracks.length < 2) return selectTrack(selected + 1, true);
|
||||
if (!shuffleBag.length) {
|
||||
shuffleBag = tracks.map((_, index) => index).filter(index => index !== selected);
|
||||
for (let i = shuffleBag.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffleBag[i], shuffleBag[j]] = [shuffleBag[j], shuffleBag[i]];
|
||||
}
|
||||
}
|
||||
selectTrack(shuffleBag.pop(), true);
|
||||
}
|
||||
function renderPlaylist() {
|
||||
$('#playlist').replaceChildren();
|
||||
tracks.forEach((track, index) => {
|
||||
@@ -149,9 +222,15 @@ async function playAudio() {
|
||||
try { await audio.play(); $('#audio-message').textContent = 'Lecture locale · aucun fichier envoyé au serveur.'; }
|
||||
catch { $('#audio-message').textContent = 'Lecture impossible : réessayez ou choisissez un format audio pris en charge.'; }
|
||||
}
|
||||
function selectTrack(index, play = false) {
|
||||
function selectTrack(index, play = false, remember = true) {
|
||||
if (!tracks.length) return;
|
||||
previousTap = -Infinity;
|
||||
if (remember && selected >= 0 && index !== selected) {
|
||||
playbackHistory.push(selected);
|
||||
if (playbackHistory.length > 256) playbackHistory.shift();
|
||||
}
|
||||
selected = (index + tracks.length) % tracks.length;
|
||||
shuffleBag = shuffleBag.filter(index => index !== selected);
|
||||
audio.src = tracks[selected].url;
|
||||
$('#track-title').textContent = tracks[selected].name;
|
||||
$('#home-track').textContent = tracks[selected].name;
|
||||
@@ -165,17 +244,30 @@ $('#music-files').addEventListener('change', event => {
|
||||
if (!file.type.startsWith('audio/') && !/\.(mp3|flac|ogg|wav|m4a|aac|opus)$/i.test(file.name)) continue;
|
||||
tracks.push({ name: file.name.replace(/\.[^.]+$/, ''), url: URL.createObjectURL(file) }); added++;
|
||||
}
|
||||
shuffleBag = [];
|
||||
renderPlaylist();
|
||||
if (selected === -1 && tracks.length) selectTrack(0);
|
||||
toast(added ? `${added} morceau${added > 1 ? 'x' : ''} ajouté${added > 1 ? 's' : ''}` : 'Aucun fichier audio reconnu');
|
||||
event.target.value = '';
|
||||
});
|
||||
$('#play').onclick = () => { if (selected === -1) return; if (audio.paused) playAudio(); else audio.pause(); };
|
||||
$('#previous').onclick = () => selectTrack(selected - 1, true);
|
||||
$('#next').onclick = () => selectTrack(selected + 1, true);
|
||||
let previousTap = -Infinity;
|
||||
$('#previous').onclick = () => {
|
||||
const now = performance.now(), twice = now - previousTap < 600;
|
||||
previousTap = twice ? -Infinity : now;
|
||||
if (!twice) { audio.currentTime = 0; return; }
|
||||
if (shuffle) { shuffleBag = []; selectTrack(playbackHistory.pop() ?? selected, true, false); }
|
||||
else selectTrack(selected - 1, true);
|
||||
};
|
||||
$('#next').onclick = nextTrack;
|
||||
audio.addEventListener('play', () => $('#play').textContent = 'Pause');
|
||||
audio.addEventListener('pause', () => $('#play').textContent = 'Lecture');
|
||||
audio.addEventListener('ended', () => { if (selected + 1 < tracks.length) selectTrack(selected + 1, true); else $('#play').textContent = 'Lecture'; });
|
||||
audio.addEventListener('ended', () => { if (audio.loop) return; if (shuffle || selected + 1 < tracks.length) nextTrack(); else $('#play').textContent = 'Lecture'; });
|
||||
audio.addEventListener('error', () => $('#audio-message').textContent = 'Ce fichier ne peut pas être lu. Essayez un autre format audio.');
|
||||
window.addEventListener('pagehide', event => { if (!event.persisted) tracks.forEach(track => URL.revokeObjectURL(track.url)); });
|
||||
renderPlaylist();
|
||||
initJourney({ storage, toast });
|
||||
initApplications({ storage, toast, navigate });
|
||||
initEmbeds({ storage, toast });
|
||||
|
||||
initCockpit({ storage, toast });
|
||||
|
||||
Reference in New Issue
Block a user