Compare commits

3 Commits

Author SHA1 Message Date
a22471a943 error page: prettier + random bible verse
Some checks failed
CI / update (push) Failing after 5s
2025-09-12 22:49:32 +02:00
4b2250ab03 cospend: require group membership for access 2025-09-12 22:27:21 +02:00
effed784b7 Enhance cospend monthly expenses chart with improved UX
- Add monthly total labels above each bar showing cumulative expense amounts
- Improve chart styling: white labels, larger fonts, clean flat tooltip design
- Hide Y-axis ticks and grid lines for cleaner appearance
- Capitalize category names in legend and tooltips
- Show only hovered category in tooltip instead of all categories
- Trim empty months from start of data for users with limited history
- Create responsive layout: balance and chart side-by-side on wide screens
- Increase max width to 1400px for dashboard while keeping recent activity at 800px
- Filter out settlements from monthly expenses view

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 22:21:22 +02:00
7 changed files with 32141 additions and 82 deletions

View File

@@ -1,4 +1,4 @@
import type { Handle } from "@sveltejs/kit"
import type { Handle, HandleServerError } from "@sveltejs/kit"
import { redirect } from "@sveltejs/kit"
import { error } from "@sveltejs/kit"
import { SvelteKitAuth } from "@auth/sveltekit"
@@ -7,24 +7,49 @@ import { AUTHENTIK_ID, AUTHENTIK_SECRET, AUTHENTIK_ISSUER } from "$env/static/pr
import { sequence } from "@sveltejs/kit/hooks"
import * as auth from "./auth"
import { initializeScheduler } from "./lib/server/scheduler"
import fs from 'fs'
import path from 'path'
// Initialize the recurring payment scheduler
initializeScheduler();
async function authorization({ event, resolve }) {
// Protect any routes under /authenticated
const session = await event.locals.auth();
// Protect rezepte routes
if (event.url.pathname.startsWith('/rezepte/edit') || event.url.pathname.startsWith('/rezepte/add')) {
const session = await event.locals.auth();
if (!session) {
// Preserve the original URL the user was trying to access
const callbackUrl = encodeURIComponent(event.url.pathname + event.url.search);
redirect(303, `/login?callbackUrl=${callbackUrl}`);
}
else if (! session.user.groups.includes('rezepte_users')) {
// strip last dir from url
// TODO: give indication of why access failed
const new_url = event.url.pathname.split('/').slice(0, -1).join('/');
redirect(303, new_url);
else if (!session.user.groups.includes('rezepte_users')) {
error(403, {
message: 'Zugriff verweigert',
details: 'Du hast keine Berechtigung für diesen Bereich. Falls du glaubst, dass dies ein Fehler ist, wende dich bitte an Alexander.'
});
}
}
// Protect cospend routes and API endpoints
if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/api/cospend')) {
if (!session) {
// For API routes, return 401 instead of redirecting
if (event.url.pathname.startsWith('/api/cospend')) {
error(401, {
message: 'Anmeldung erforderlich',
details: 'Du musst angemeldet sein, um auf diesen Bereich zugreifen zu können.'
});
}
// For page routes, redirect to login
const callbackUrl = encodeURIComponent(event.url.pathname + event.url.search);
redirect(303, `/login?callbackUrl=${callbackUrl}`);
}
else if (!session.user.groups.includes('cospend')) {
error(403, {
message: 'Zugriff verweigert',
details: 'Du hast keine Berechtigung für diesen Bereich. Falls du glaubst, dass dies ein Fehler ist, wende dich bitte an Alexander.'
});
}
}
@@ -32,6 +57,82 @@ async function authorization({ event, resolve }) {
return resolve(event);
}
// Bible verse functionality for error pages
interface BibleVerse {
bookName: string;
abbreviation: string;
chapter: number;
verse: number;
verseNumber: number;
text: string;
}
let cachedVerses: BibleVerse[] | null = null;
function loadVerses(): BibleVerse[] {
if (cachedVerses) {
return cachedVerses;
}
try {
const filePath = path.join(process.cwd(), 'static', 'allioli.tsv');
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n');
cachedVerses = lines.map(line => {
const [bookName, abbreviation, chapter, verse, verseNumber, text] = line.split('\t');
return {
bookName,
abbreviation,
chapter: parseInt(chapter),
verse: parseInt(verse),
verseNumber: parseInt(verseNumber),
text
};
});
return cachedVerses;
} catch (err) {
console.error('Error loading Bible verses:', err);
return [];
}
}
function getRandomVerse(): BibleVerse | null {
try {
const verses = loadVerses();
if (verses.length === 0) return null;
const randomIndex = Math.floor(Math.random() * verses.length);
return verses[randomIndex];
} catch (err) {
console.error('Error getting random verse:', err);
return null;
}
}
function formatVerse(verse: BibleVerse): string {
return `${verse.bookName} ${verse.chapter}:${verse.verseNumber}`;
}
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
// Add Bible verse to error context
const randomVerse = getRandomVerse();
const bibleQuote = randomVerse ? {
text: randomVerse.text,
reference: formatVerse(randomVerse),
book: randomVerse.bookName,
chapter: randomVerse.chapter,
verse: randomVerse.verseNumber
} : null;
return {
message: message,
bibleQuote
};
};
export const handle: Handle = sequence(
auth.handle,
authorization

View File

@@ -1,21 +1,21 @@
<script>
import { onMount } from 'svelte';
import { Chart, registerables } from 'chart.js';
export let data = { labels: [], datasets: [] };
export let title = '';
export let height = '400px';
let canvas;
let chart;
// Register Chart.js components
Chart.register(...registerables);
// Nord theme colors for categories
const nordColors = [
'#5E81AC', // Nord Blue
'#88C0D0', // Nord Light Blue
'#88C0D0', // Nord Light Blue
'#81A1C1', // Nord Lighter Blue
'#A3BE8C', // Nord Green
'#EBCB8B', // Nord Yellow
@@ -25,11 +25,11 @@
'#8FBCBB', // Nord Cyan
'#ECEFF4', // Nord Light Gray
];
function getCategoryColor(category, index) {
const categoryColorMap = {
'groceries': '#A3BE8C', // Green
'restaurant': '#D08770', // Orange
'restaurant': '#D08770', // Orange
'transport': '#5E81AC', // Blue
'entertainment': '#B48EAD', // Purple
'shopping': '#EBCB8B', // Yellow
@@ -39,28 +39,29 @@
'travel': '#8FBCBB', // Light Cyan
'other': '#4C566A' // Dark Gray
};
return categoryColorMap[category] || nordColors[index % nordColors.length];
}
function createChart() {
if (!canvas || !data.datasets) return;
// Destroy existing chart
if (chart) {
chart.destroy();
}
const ctx = canvas.getContext('2d');
// Process datasets with colors
// Process datasets with colors and capitalize labels
const processedDatasets = data.datasets.map((dataset, index) => ({
...dataset,
label: dataset.label.charAt(0).toUpperCase() + dataset.label.slice(1),
backgroundColor: getCategoryColor(dataset.label, index),
borderColor: getCategoryColor(dataset.label, index),
borderWidth: 1
}));
chart = new Chart(ctx, {
type: 'bar',
data: {
@@ -70,16 +71,26 @@
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 40
}
},
scales: {
x: {
stacked: true,
grid: {
display: false
},
border: {
display: false
},
ticks: {
color: 'var(--nord3)',
color: '#ffffff',
font: {
family: 'Inter, system-ui, sans-serif'
family: 'Inter, system-ui, sans-serif',
size: 14,
weight: 'bold'
}
}
},
@@ -87,58 +98,74 @@
stacked: true,
beginAtZero: true,
grid: {
color: 'var(--nord4)',
borderDash: [2, 2]
display: false
},
border: {
display: false
},
ticks: {
color: 'var(--nord3)',
color: 'transparent',
font: {
family: 'Inter, system-ui, sans-serif'
},
callback: function(value) {
return 'CHF ' + value.toFixed(0);
size: 0
}
}
}
},
plugins: {
datalabels: {
display: false
},
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true,
color: 'var(--nord1)',
color: '#ffffff',
font: {
family: 'Inter, system-ui, sans-serif',
size: 12
size: 14,
weight: 'bold'
}
}
},
title: {
display: !!title,
text: title,
color: 'var(--nord0)',
color: '#ffffff',
font: {
family: 'Inter, system-ui, sans-serif',
size: 16,
size: 18,
weight: 'bold'
},
padding: 20
},
tooltip: {
backgroundColor: 'var(--nord1)',
titleColor: 'var(--nord6)',
bodyColor: 'var(--nord6)',
borderColor: 'var(--nord3)',
borderWidth: 1,
cornerRadius: 8,
backgroundColor: '#2e3440',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderWidth: 0,
cornerRadius: 12,
padding: 12,
displayColors: true,
titleAlign: 'center',
bodyAlign: 'center',
titleFont: {
family: 'Inter, system-ui, sans-serif'
family: 'Inter, system-ui, sans-serif',
size: 13,
weight: 'bold'
},
bodyFont: {
family: 'Inter, system-ui, sans-serif'
family: 'Inter, system-ui, sans-serif',
size: 14,
weight: '500'
},
titleMarginBottom: 8,
usePointStyle: true,
boxPadding: 6,
callbacks: {
title: function(context) {
return '';
},
label: function(context) {
return context.dataset.label + ': CHF ' + context.parsed.y.toFixed(2);
}
@@ -146,24 +173,67 @@
}
},
interaction: {
intersect: false,
mode: 'index'
intersect: true,
mode: 'dataset'
}
}
},
plugins: [{
id: 'monthlyTotals',
afterDatasetsDraw: function(chart) {
const ctx = chart.ctx;
const chartArea = chart.chartArea;
ctx.save();
ctx.font = 'bold 14px Inter, system-ui, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
// Calculate and display monthly totals
chart.data.labels.forEach((label, index) => {
let total = 0;
chart.data.datasets.forEach(dataset => {
total += dataset.data[index] || 0;
});
if (total > 0) {
// Get the x position for this month from any dataset
const meta = chart.getDatasetMeta(0);
if (meta && meta.data[index]) {
const x = meta.data[index].x;
// Find the highest point for this month across all datasets
let maxY = chartArea.bottom;
for (let datasetIndex = 0; datasetIndex < chart.data.datasets.length; datasetIndex++) {
const datasetMeta = chart.getDatasetMeta(datasetIndex);
if (datasetMeta && datasetMeta.data[index]) {
maxY = Math.min(maxY, datasetMeta.data[index].y);
}
}
// Display the total above the bar
ctx.fillText(`CHF ${total.toFixed(0)}`, x, maxY - 10);
}
}
});
ctx.restore();
}
}]
});
}
onMount(() => {
createChart();
// Watch for theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleThemeChange = () => {
setTimeout(createChart, 100); // Small delay to let CSS variables update
};
mediaQuery.addEventListener('change', handleThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleThemeChange);
if (chart) {
@@ -171,7 +241,7 @@
}
};
});
// Recreate chart when data changes
$: if (canvas && data) {
createChart();
@@ -190,16 +260,16 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--nord4);
}
@media (prefers-color-scheme: dark) {
.chart-container {
background: var(--nord1);
border-color: var(--nord2);
}
}
canvas {
max-width: 100%;
height: 100% !important;
}
</style>
</style>

393
src/routes/+error.svelte Normal file
View File

@@ -0,0 +1,393 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import Header from '$lib/components/Header.svelte';
$: status = $page.status;
$: error = $page.error;
// Get session data if available (may not be available in error context)
$: session = $page.data?.session;
$: user = session?.user;
// Get Bible quote from SSR via handleError hook
$: bibleQuote = $page.error?.bibleQuote;
function getErrorTitle(status) {
switch (status) {
case 401:
return 'Anmeldung erforderlich';
case 403:
return 'Zugriff verweigert';
case 404:
return 'Seite nicht gefunden';
case 500:
return 'Serverfehler';
default:
return 'Fehler';
}
}
function getErrorDescription(status) {
switch (status) {
case 401:
return 'Du musst angemeldet sein, um auf diese Seite zugreifen zu können.';
case 403:
return 'Du hast keine Berechtigung für diesen Bereich.';
case 404:
return 'Die angeforderte Seite konnte nicht gefunden werden.';
case 500:
return 'Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es später erneut.';
default:
return 'Es ist ein unerwarteter Fehler aufgetreten.';
}
}
function getErrorIcon(status) {
switch (status) {
case 401:
return '🔐';
case 403:
return '🚫';
case 404:
return '🔍';
case 500:
return '⚠️';
default:
return '❌';
}
}
function goHome() {
goto('/');
}
function goBack() {
if (window.history.length > 1) {
window.history.back();
} else {
goto('/');
}
}
function login() {
goto('/login');
}
</script>
<svelte:head>
<title>{getErrorTitle(status)} - Alexander's Website</title>
</svelte:head>
<Header>
<ul class="site_header" slot="links">
</ul>
<main class="error-page">
<div class="error-container">
<div class="error-icon">
{getErrorIcon(status)}
</div>
<h1 class="error-title">
{getErrorTitle(status)}
</h1>
<div class="error-code">
Fehler {status}
</div>
<p class="error-description">
{getErrorDescription(status)}
</p>
{#if error?.details}
<div class="error-details">
{error.details}
</div>
{/if}
<div class="error-actions">
{#if status === 401}
<button class="btn btn-primary" on:click={login}>
Anmelden
</button>
<button class="btn btn-secondary" on:click={goHome}>
Zur Startseite
</button>
{:else if status === 403}
<button class="btn btn-primary" on:click={goHome}>
Zur Startseite
</button>
<button class="btn btn-secondary" on:click={goBack}>
Zurück
</button>
{:else if status === 404}
<button class="btn btn-primary" on:click={goHome}>
Zur Startseite
</button>
<button class="btn btn-secondary" on:click={goBack}>
Zurück
</button>
{:else if status === 500}
<button class="btn btn-primary" on:click={goHome}>
Zur Startseite
</button>
<button class="btn btn-secondary" on:click={goBack}>
Erneut versuchen
</button>
{:else}
<button class="btn btn-primary" on:click={goHome}>
Zur Startseite
</button>
<button class="btn btn-secondary" on:click={goBack}>
Zurück
</button>
{/if}
</div>
<!-- Bible Quote Section -->
{#if bibleQuote}
<div class="bible-quote">
<div class="quote-text">
{bibleQuote.text}"
</div>
<div class="quote-reference">
{bibleQuote.reference}
</div>
</div>
{/if}
</div>
</main>
</Header>
<style>
.error-page {
min-height: calc(100vh - 4rem);
display: flex;
align-items: center;
justify-content: center;
background: #fbf9f3;
padding: 2rem;
}
@media (prefers-color-scheme: dark) {
.error-page {
background: var(--background-dark);
}
}
.error-container {
background: var(--nord5);
border-radius: 1rem;
padding: 3rem;
max-width: 600px;
width: 100%;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid var(--nord4);
}
@media (prefers-color-scheme: dark) {
.error-container {
background: var(--nord1);
border-color: var(--nord2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
}
.error-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
opacity: 0.8;
}
.error-title {
font-size: 2.5rem;
color: var(--nord0);
margin-bottom: 0.5rem;
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
.error-title {
color: var(--nord6);
}
}
.error-code {
font-size: 1.2rem;
color: var(--nord3);
font-weight: 600;
margin-bottom: 1.5rem;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) {
.error-code {
color: var(--nord4);
}
}
.error-description {
font-size: 1.1rem;
color: var(--nord2);
margin-bottom: 1rem;
line-height: 1.6;
}
@media (prefers-color-scheme: dark) {
.error-description {
color: var(--nord5);
}
}
.error-details {
background: var(--nord4);
border-radius: 0.5rem;
padding: 1rem;
margin: 1.5rem 0;
font-size: 0.9rem;
color: var(--nord0);
border-left: 4px solid var(--blue);
}
@media (prefers-color-scheme: dark) {
.error-details {
background: var(--nord2);
color: var(--nord6);
}
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-size: 1rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--blue), var(--lightblue));
color: white;
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--lightblue), var(--blue));
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(94, 129, 172, 0.3);
}
.btn-secondary {
background: var(--nord4);
color: var(--nord0);
border: 1px solid var(--nord3);
}
.btn-secondary:hover {
background: var(--nord3);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
@media (prefers-color-scheme: dark) {
.btn-secondary {
background: var(--nord2);
color: var(--nord6);
border-color: var(--nord3);
}
.btn-secondary:hover {
background: var(--nord3);
}
}
.bible-quote {
margin: 2.5rem 0;
padding: 2rem;
background: linear-gradient(135deg, var(--nord5), var(--nord4));
border-radius: 0.75rem;
border-left: 4px solid var(--blue);
font-style: italic;
}
@media (prefers-color-scheme: dark) {
.bible-quote {
background: linear-gradient(135deg, var(--nord2), var(--nord3));
}
}
.quote-text {
font-size: 1.1rem;
line-height: 1.6;
color: var(--nord0);
margin-bottom: 1rem;
text-align: left;
}
@media (prefers-color-scheme: dark) {
.quote-text {
color: var(--nord6);
}
}
.quote-reference {
font-size: 0.9rem;
color: var(--nord2);
font-weight: 600;
text-align: right;
font-style: normal;
}
@media (prefers-color-scheme: dark) {
.quote-reference {
color: var(--nord4);
}
}
@media (max-width: 600px) {
.error-container {
padding: 2rem;
margin: 1rem;
}
.error-title {
font-size: 2rem;
}
.error-actions {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 250px;
}
.bible-quote {
padding: 1.5rem;
}
.quote-text {
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,72 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import fs from 'fs';
import path from 'path';
interface BibleVerse {
bookName: string;
abbreviation: string;
chapter: number;
verse: number;
verseNumber: number;
text: string;
}
// Cache for parsed verses to avoid reading file repeatedly
let cachedVerses: BibleVerse[] | null = null;
function loadVerses(): BibleVerse[] {
if (cachedVerses) {
return cachedVerses;
}
try {
const filePath = path.join(process.cwd(), 'static', 'allioli.tsv');
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n');
cachedVerses = lines.map(line => {
const [bookName, abbreviation, chapter, verse, verseNumber, text] = line.split('\t');
return {
bookName,
abbreviation,
chapter: parseInt(chapter),
verse: parseInt(verse),
verseNumber: parseInt(verseNumber),
text
};
});
return cachedVerses;
} catch (err) {
console.error('Error loading Bible verses:', err);
throw new Error('Failed to load Bible verses');
}
}
function getRandomVerse(verses: BibleVerse[]): BibleVerse {
const randomIndex = Math.floor(Math.random() * verses.length);
return verses[randomIndex];
}
function formatVerse(verse: BibleVerse): string {
return `${verse.bookName} ${verse.chapter}:${verse.verseNumber}`;
}
export const GET: RequestHandler = async () => {
try {
const verses = loadVerses();
const randomVerse = getRandomVerse(verses);
return json({
text: randomVerse.text,
reference: formatVerse(randomVerse),
book: randomVerse.bookName,
chapter: randomVerse.chapter,
verse: randomVerse.verseNumber
});
} catch (err) {
console.error('Error fetching random Bible verse:', err);
return error(500, 'Failed to fetch Bible verse');
}
};

View File

@@ -106,9 +106,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
});
// Convert to arrays for Chart.js
const months = Array.from(monthsMap.keys()).sort();
const allMonths = Array.from(monthsMap.keys()).sort();
const categoryList = Array.from(categories).sort();
// Find the first month with any data and trim empty months from the start
let firstMonthWithData = 0;
for (let i = 0; i < allMonths.length; i++) {
const monthData = monthsMap.get(allMonths[i]);
const hasData = Object.values(monthData).some(value => value > 0);
if (hasData) {
firstMonthWithData = i;
break;
}
}
// Trim the months array to start from the first month with data
const months = allMonths.slice(firstMonthWithData);
const datasets = categoryList.map((category: string) => ({
label: category,
data: months.map(month => monthsMap.get(month)[category] || 0)

View File

@@ -153,33 +153,38 @@
<main class="cospend-main">
<h1>Cospend</h1>
<EnhancedBalance bind:this={enhancedBalanceComponent} initialBalance={data.balance} initialDebtData={data.debtData} />
<!-- Responsive layout for balance and chart -->
<div class="dashboard-layout">
<div class="balance-section">
<EnhancedBalance bind:this={enhancedBalanceComponent} initialBalance={data.balance} initialDebtData={data.debtData} />
<div class="actions">
{#if balance.netBalance !== 0}
<a href="/cospend/settle" class="btn btn-settlement">Settle Debts</a>
{/if}
</div>
<DebtBreakdown bind:this={debtBreakdownComponent} />
<!-- Monthly Expenses Chart -->
<div class="chart-section">
{#if expensesLoading}
<div class="loading">Loading monthly expenses chart...</div>
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
<BarChart
data={monthlyExpensesData}
title="Monthly Expenses by Category"
height="400px"
/>
{:else}
<div class="loading">
Debug: expensesLoading={expensesLoading},
datasets={monthlyExpensesData.datasets?.length || 0},
data={JSON.stringify(monthlyExpensesData)}
<div class="actions">
{#if balance.netBalance !== 0}
<a href="/cospend/settle" class="btn btn-settlement">Settle Debts</a>
{/if}
</div>
{/if}
<DebtBreakdown bind:this={debtBreakdownComponent} />
</div>
<!-- Monthly Expenses Chart -->
<div class="chart-section">
{#if expensesLoading}
<div class="loading">Loading monthly expenses chart...</div>
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
<BarChart
data={monthlyExpensesData}
title="Monthly Expenses by Category"
height="400px"
/>
{:else}
<div class="loading">
Debug: expensesLoading={expensesLoading},
datasets={monthlyExpensesData.datasets?.length || 0},
data={JSON.stringify(monthlyExpensesData)}
</div>
{/if}
</div>
</div>
{#if loading}
@@ -274,7 +279,6 @@
<style>
.cospend-main {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
@@ -355,6 +359,9 @@
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--nord4);
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.recent-activity h2 {
@@ -705,8 +712,33 @@
}
}
.chart-section {
.dashboard-layout {
display: flex;
flex-direction: column;
gap: 2rem;
margin-bottom: 2rem;
max-width: 1400px;
margin-left: auto;
margin-right: auto;
}
@media (min-width: 1200px) {
.dashboard-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
align-items: start;
}
.balance-section {
display: flex;
flex-direction: column;
gap: 2rem;
}
}
.chart-section {
min-height: 400px;
}
.chart-section .loading {

31377
static/allioli.tsv Normal file

File diff suppressed because it is too large Load Diff