mirror of
https://github.com/ellite/Wallos.git
synced 2026-07-30 17:55:53 -04:00
feat: option for the week to start on sunday feat: redesign login / registration pages feat: more statistics feat: declarative oidc settings feat: grid view for subscriptions feat: subscription details popup feat: translate categories with ai feat: google image search with serpapi feat: selfh.st image search feat: dashboard icons image search fix: improve background removal feature for logos feat: v2.0 api - write endpoints fix: include todays subscriptions on amount due this month fix: calendar occurrences to respect subscription start date fix: honor configured outbound proxy for logo search without reopening httpoxy SSRF bypass fix: remove hardcode string from the admin page fix: ssrf via http proxy env var in payments logo search fix: require cron auth guard on storetotalyearlycost.php fix: validate per-user smtp host against ssrf fix: escape iCal property values to prevent crlf injection
324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
const CHART_PALETTE = ['#008FFB','#00E396','#FEB019','#FF4560','#775DD0','#546E7A','#26a69a','#D10CE8'];
|
|
|
|
function _chartTheme() {
|
|
const cs = getComputedStyle(document.documentElement);
|
|
const main = cs.getPropertyValue('--main-color').trim() || '#007BFF';
|
|
const text = cs.getPropertyValue('--text-color').trim() || '#202020';
|
|
const border = cs.getPropertyValue('--box-border-color').trim() || '#E8E8E8';
|
|
const dark = document.body.classList.contains('dark');
|
|
const font = "Barlow, 'Helvetica Neue', Helvetica, sans-serif";
|
|
return { main, text, border, dark, font };
|
|
}
|
|
|
|
function loadGraph(container, dataPoints, currency, run) {
|
|
if (!run) return;
|
|
|
|
const t = _chartTheme();
|
|
const fmt = val => currency
|
|
? new Intl.NumberFormat(navigator.language, { style: 'currency', currency }).format(val)
|
|
: new Intl.NumberFormat(navigator.language).format(val);
|
|
|
|
const hidden = new Array(dataPoints.length).fill(false);
|
|
|
|
function activeSubset() {
|
|
const pts = dataPoints.filter((_, i) => !hidden[i]);
|
|
return {
|
|
series: pts.map(p => p.y),
|
|
labels: pts.map(p => `${p.label} (${fmt(p.y)})`),
|
|
colors: CHART_PALETTE.filter((_, i) => !hidden[i]).slice(0, pts.length),
|
|
};
|
|
}
|
|
|
|
const el = document.getElementById(container);
|
|
|
|
const chart = new ApexCharts(el, {
|
|
chart: {
|
|
type: 'donut',
|
|
height: 320,
|
|
background: 'transparent',
|
|
fontFamily: t.font,
|
|
},
|
|
theme: { mode: t.dark ? 'dark' : 'light' },
|
|
...activeSubset(),
|
|
legend: { show: false },
|
|
dataLabels: {
|
|
style: { fontFamily: t.font, fontSize: '12px' },
|
|
dropShadow: { enabled: false },
|
|
},
|
|
plotOptions: { pie: { donut: { size: '55%' } } },
|
|
stroke: { width: 0 },
|
|
tooltip: {
|
|
style: { fontFamily: t.font },
|
|
y: { formatter: fmt },
|
|
},
|
|
});
|
|
chart.render();
|
|
|
|
const legend = document.createElement('div');
|
|
legend.className = 'graph-legend';
|
|
dataPoints.forEach((p, i) => {
|
|
const item = document.createElement('button');
|
|
item.className = 'graph-legend-item';
|
|
item.innerHTML = `<span class="graph-legend-dot" style="background:${CHART_PALETTE[i % CHART_PALETTE.length]}"></span>${p.label} (${fmt(p.y)})`;
|
|
item.addEventListener('click', () => {
|
|
hidden[i] = !hidden[i];
|
|
item.classList.toggle('graph-legend-item--off', hidden[i]);
|
|
chart.updateOptions(activeSubset());
|
|
});
|
|
legend.appendChild(item);
|
|
});
|
|
el.after(legend);
|
|
}
|
|
|
|
function loadLineGraph(container, dataPoints, currency, run) {
|
|
if (!run) return;
|
|
|
|
const t = _chartTheme();
|
|
const fmt = val => currency
|
|
? new Intl.NumberFormat(navigator.language, { style: 'currency', currency }).format(val)
|
|
: new Intl.NumberFormat(navigator.language).format(val);
|
|
|
|
// Mark the 12-month high and low so the extremes read at a glance
|
|
const values = dataPoints.map(p => p.y);
|
|
const discreteMarkers = [];
|
|
if (values.length > 2) {
|
|
const maxIndex = values.indexOf(Math.max(...values));
|
|
const minIndex = values.indexOf(Math.min(...values));
|
|
if (maxIndex !== minIndex) {
|
|
discreteMarkers.push(
|
|
{ seriesIndex: 0, dataPointIndex: maxIndex, size: 5, fillColor: t.main, strokeColor: t.dark ? '#171B23' : '#FFFFFF' },
|
|
{ seriesIndex: 0, dataPointIndex: minIndex, size: 5, fillColor: t.main, strokeColor: t.dark ? '#171B23' : '#FFFFFF' }
|
|
);
|
|
}
|
|
}
|
|
|
|
const chart = new ApexCharts(document.getElementById(container), {
|
|
chart: {
|
|
type: 'area',
|
|
height: 370,
|
|
background: 'transparent',
|
|
fontFamily: t.font,
|
|
toolbar: { show: false },
|
|
zoom: { enabled: false },
|
|
},
|
|
theme: { mode: t.dark ? 'dark' : 'light' },
|
|
series: [{ name: currency || '', data: dataPoints.map(p => p.y) }],
|
|
xaxis: {
|
|
categories: dataPoints.map(p => p.label),
|
|
labels: { style: { fontFamily: t.font, colors: t.text } },
|
|
},
|
|
yaxis: {
|
|
labels: {
|
|
formatter: fmt,
|
|
style: { fontFamily: t.font, colors: t.text },
|
|
},
|
|
},
|
|
colors: [t.main],
|
|
stroke: { curve: 'smooth', width: 2 },
|
|
fill: {
|
|
type: 'gradient',
|
|
gradient: { opacityFrom: 0.35, opacityTo: 0.0 },
|
|
},
|
|
markers: { size: 0, discrete: discreteMarkers, hover: { size: 5 } },
|
|
grid: { borderColor: t.border },
|
|
tooltip: {
|
|
style: { fontFamily: t.font },
|
|
y: { formatter: fmt },
|
|
},
|
|
legend: { show: false },
|
|
});
|
|
chart.render();
|
|
}
|
|
|
|
function loadBarGraph(container, dataPoints, currency, run, thresholdLine) {
|
|
if (!run) return;
|
|
|
|
const t = _chartTheme();
|
|
const cs = getComputedStyle(document.documentElement);
|
|
const errorColor = cs.getPropertyValue('--error-color').trim() || '#EF4444';
|
|
const fmt = val => currency
|
|
? new Intl.NumberFormat(navigator.language, { style: 'currency', currency }).format(val)
|
|
: new Intl.NumberFormat(navigator.language).format(val);
|
|
|
|
const annotations = {};
|
|
if (thresholdLine !== null && thresholdLine !== undefined) {
|
|
annotations.yaxis = [{
|
|
y: thresholdLine,
|
|
borderColor: errorColor,
|
|
strokeDashArray: 5,
|
|
label: {
|
|
text: fmt(thresholdLine),
|
|
position: 'left',
|
|
textAnchor: 'start',
|
|
style: {
|
|
color: errorColor,
|
|
background: 'transparent',
|
|
fontFamily: t.font,
|
|
},
|
|
},
|
|
}];
|
|
}
|
|
|
|
const chart = new ApexCharts(document.getElementById(container), {
|
|
chart: {
|
|
type: 'bar',
|
|
height: 320,
|
|
background: 'transparent',
|
|
fontFamily: t.font,
|
|
toolbar: { show: false },
|
|
zoom: { enabled: false },
|
|
},
|
|
theme: { mode: t.dark ? 'dark' : 'light' },
|
|
series: [{ name: currency || '', data: dataPoints.map(p => p.y) }],
|
|
xaxis: {
|
|
categories: dataPoints.map(p => p.label),
|
|
labels: { style: { fontFamily: t.font, colors: t.text } },
|
|
},
|
|
yaxis: {
|
|
labels: {
|
|
formatter: fmt,
|
|
style: { fontFamily: t.font, colors: t.text },
|
|
},
|
|
},
|
|
colors: [t.main],
|
|
plotOptions: {
|
|
bar: {
|
|
columnWidth: '55%',
|
|
borderRadius: 4,
|
|
borderRadiusApplication: 'end',
|
|
},
|
|
},
|
|
dataLabels: { enabled: false },
|
|
annotations: annotations,
|
|
grid: { borderColor: t.border },
|
|
tooltip: {
|
|
style: { fontFamily: t.font },
|
|
y: { formatter: fmt },
|
|
},
|
|
legend: { show: false },
|
|
});
|
|
chart.render();
|
|
}
|
|
|
|
function loadHorizontalBarGraph(container, dataPoints, currency, run) {
|
|
if (!run) return;
|
|
|
|
const t = _chartTheme();
|
|
const fmt = val => currency
|
|
? new Intl.NumberFormat(navigator.language, { style: 'currency', currency }).format(val)
|
|
: new Intl.NumberFormat(navigator.language).format(val);
|
|
|
|
const chart = new ApexCharts(document.getElementById(container), {
|
|
chart: {
|
|
type: 'bar',
|
|
height: Math.max(200, dataPoints.length * 36 + 60),
|
|
background: 'transparent',
|
|
fontFamily: t.font,
|
|
toolbar: { show: false },
|
|
zoom: { enabled: false },
|
|
},
|
|
theme: { mode: t.dark ? 'dark' : 'light' },
|
|
series: [{ name: currency || '', data: dataPoints.map(p => p.y) }],
|
|
xaxis: {
|
|
categories: dataPoints.map(p => p.label),
|
|
labels: {
|
|
formatter: fmt,
|
|
style: { fontFamily: t.font, colors: t.text },
|
|
},
|
|
},
|
|
yaxis: {
|
|
labels: { style: { fontFamily: t.font, colors: t.text } },
|
|
},
|
|
colors: [t.main],
|
|
plotOptions: {
|
|
bar: {
|
|
horizontal: true,
|
|
barHeight: '55%',
|
|
borderRadius: 4,
|
|
borderRadiusApplication: 'end',
|
|
},
|
|
},
|
|
dataLabels: { enabled: false },
|
|
grid: { borderColor: t.border },
|
|
tooltip: {
|
|
style: { fontFamily: t.font },
|
|
y: { formatter: fmt },
|
|
},
|
|
legend: { show: false },
|
|
});
|
|
chart.render();
|
|
}
|
|
|
|
|
|
function closeSubMenus() {
|
|
var subMenus = document.querySelectorAll('.filtermenu-submenu-content');
|
|
subMenus.forEach(subMenu => {
|
|
subMenu.classList.remove('is-open');
|
|
});
|
|
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", function() {
|
|
var filtermenu = document.querySelector('#filtermenu-button');
|
|
filtermenu.addEventListener('click', function() {
|
|
this.parentElement.querySelector('.filtermenu-content').classList.toggle('is-open');
|
|
closeSubMenus();
|
|
});
|
|
|
|
document.addEventListener('click', function(e) {
|
|
var filtermenuContent = document.querySelector('.filtermenu-content');
|
|
if (filtermenuContent.classList.contains('is-open')) {
|
|
var subMenus = document.querySelectorAll('.filtermenu-submenu');
|
|
var clickedInsideSubmenu = Array.from(subMenus).some(subMenu => subMenu.contains(e.target) || subMenu === e.target);
|
|
|
|
if (!filtermenu.contains(e.target) && !clickedInsideSubmenu) {
|
|
closeSubMenus();
|
|
filtermenuContent.classList.remove('is-open');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
function toggleSubMenu(subMenu) {
|
|
var subMenu = document.getElementById("filter-" + subMenu);
|
|
if (subMenu.classList.contains("is-open")) {
|
|
closeSubMenus();
|
|
} else {
|
|
closeSubMenus();
|
|
subMenu.classList.add("is-open");
|
|
}
|
|
}
|
|
|
|
document.querySelectorAll('.filter-item').forEach(function(item) {
|
|
item.addEventListener('click', function(e) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
let newUrl = 'stats.php?';
|
|
|
|
if (this.hasAttribute('data-categoryid')) {
|
|
const categoryId = this.getAttribute('data-categoryid');
|
|
const current = urlParams.get('category') ? urlParams.get('category').split(',') : [];
|
|
const idx = current.indexOf(categoryId);
|
|
if (idx !== -1) { current.splice(idx, 1); } else { current.push(categoryId); }
|
|
current.length ? urlParams.set('category', current.join(',')) : urlParams.delete('category');
|
|
} else if (this.hasAttribute('data-memberid')) {
|
|
const memberId = this.getAttribute('data-memberid');
|
|
const current = urlParams.get('member') ? urlParams.get('member').split(',') : [];
|
|
const idx = current.indexOf(memberId);
|
|
if (idx !== -1) { current.splice(idx, 1); } else { current.push(memberId); }
|
|
current.length ? urlParams.set('member', current.join(',')) : urlParams.delete('member');
|
|
} else if (this.hasAttribute('data-paymentid')) {
|
|
const paymentId = this.getAttribute('data-paymentid');
|
|
const current = urlParams.get('payment') ? urlParams.get('payment').split(',') : [];
|
|
const idx = current.indexOf(paymentId);
|
|
if (idx !== -1) { current.splice(idx, 1); } else { current.push(paymentId); }
|
|
current.length ? urlParams.set('payment', current.join(',')) : urlParams.delete('payment');
|
|
}
|
|
|
|
newUrl += urlParams.toString();
|
|
window.location.href = newUrl;
|
|
});
|
|
});
|
|
|
|
function clearFilters() {
|
|
window.location.href = 'stats.php';
|
|
} |