Compare commits
	
		
			3 Commits
		
	
	
		
			b03ba61599
			...
			a22471a943
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						a22471a943
	
				 | 
					
					
						|||
| 
						
						
							
						
						4b2250ab03
	
				 | 
					
					
						|||
| 
						
						
							
						
						effed784b7
	
				 | 
					
					
						
@@ -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
 | 
			
		||||
	if (event.url.pathname.startsWith('/rezepte/edit') || event.url.pathname.startsWith('/rezepte/add')) {
 | 
			
		||||
	const session = await event.locals.auth();
 | 
			
		||||
	
 | 
			
		||||
	// Protect rezepte routes
 | 
			
		||||
	if (event.url.pathname.startsWith('/rezepte/edit') || event.url.pathname.startsWith('/rezepte/add')) {
 | 
			
		||||
		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
 | 
			
		||||
 
 | 
			
		||||
@@ -53,9 +53,10 @@
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
@@ -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,10 +173,53 @@
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        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();
 | 
			
		||||
        }
 | 
			
		||||
      }]
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										393
									
								
								src/routes/+error.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										393
									
								
								src/routes/+error.svelte
									
									
									
									
									
										Normal 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>
 | 
			
		||||
							
								
								
									
										72
									
								
								src/routes/api/bible-quote/+server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/routes/api/bible-quote/+server.ts
									
									
									
									
									
										Normal 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');
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -153,6 +153,9 @@
 | 
			
		||||
<main class="cospend-main">
 | 
			
		||||
    <h1>Cospend</h1>
 | 
			
		||||
 | 
			
		||||
  <!-- 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">
 | 
			
		||||
@@ -162,6 +165,7 @@
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <DebtBreakdown bind:this={debtBreakdownComponent} />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Monthly Expenses Chart -->
 | 
			
		||||
    <div class="chart-section">
 | 
			
		||||
@@ -181,6 +185,7 @@
 | 
			
		||||
        </div>
 | 
			
		||||
      {/if}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  {#if loading}
 | 
			
		||||
    <div class="loading">Loading recent activity...</div>
 | 
			
		||||
@@ -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
									
								
							
							
						
						
									
										31377
									
								
								static/allioli.tsv
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user