🔧 debug(bot-services): add logging to getToken for SSO-Link debugging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-13 14:06:37 +01:00
parent 7e1e8e9378
commit ef9bd5656d
34 changed files with 2340 additions and 2149 deletions

View file

@ -0,0 +1,52 @@
{
"name": "@zitare/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@manacore/shared-vite-config": "workspace:*",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.9.3",
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-feedback-ui": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@zitare/content": "workspace:*",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,63 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-theme-ui/src/components";
@source "../../../../packages/shared-theme-ui/src/pages";
/* Quote-specific styles */
.quote-text {
font-family: Georgia, 'Times New Roman', serif;
font-style: italic;
line-height: 1.8;
}
.quote-author {
font-family: system-ui, -apple-system, sans-serif;
font-weight: 500;
}
/* Gradient backgrounds for quote cards */
.quote-gradient-wisdom {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
}
.quote-gradient-motivation {
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
}
.quote-gradient-love {
background: linear-gradient(135deg, #ec4899 0%, #f43f5e 100%);
}
.quote-gradient-life {
background: linear-gradient(135deg, #10b981 0%, #06b6d4 100%);
}
.quote-gradient-success {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.quote-gradient-happiness {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
}
.quote-gradient-friendship {
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
}
.quote-gradient-courage {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.quote-gradient-hope {
background: linear-gradient(135deg, #14b8a6 0%, #0ea5e9 100%);
}
.quote-gradient-nature {
background: linear-gradient(135deg, #22c55e 0%, #10b981 100%);
}

View file

@ -0,0 +1,21 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#8b5cf6" />
<meta name="description" content="Zitare - Inspirierende Zitate jeden Tag" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<title>Zitare</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<script>
// Inject environment variables for client-side use
window.__PUBLIC_MANA_CORE_AUTH_URL__ = '%env:PUBLIC_MANA_CORE_AUTH_URL_CLIENT%' || '%env:PUBLIC_MANA_CORE_AUTH_URL%';
window.__PUBLIC_BACKEND_URL__ = '%env:PUBLIC_BACKEND_URL_CLIENT%' || '%env:PUBLIC_BACKEND_URL%';
</script>
</body>
</html>

View file

@ -1,455 +0,0 @@
<script lang="ts">
import type { Author } from '@zitare/shared';
import { createEventDispatcher } from 'svelte';
interface Props {
author: Author & { quoteCount?: number };
variant?: 'simple' | 'enhanced';
isFavorite?: boolean;
}
let { author, variant = 'enhanced', isFavorite = false }: Props = $props();
const dispatch = createEventDispatcher();
// Get gradient colors based on profession
function getGradientColors(author: Author): string {
if (author.featured) {
return 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)'; // Amber to Red for featured
}
const profession = author.profession?.[0]?.toLowerCase() || '';
if (profession.includes('philosoph')) {
return 'linear-gradient(135deg, #9333ea 0%, #6366f1 100%)'; // Purple to Indigo
} else if (profession.includes('dichter') || profession.includes('poet')) {
return 'linear-gradient(135deg, #ec4899 0%, #f43f5e 100%)'; // Pink to Rose
} else if (profession.includes('wissenschaft')) {
return 'linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%)'; // Blue to Cyan
} else if (profession.includes('schrift')) {
return 'linear-gradient(135deg, #10b981 0%, #14b8a6 100%)'; // Emerald to Teal
}
return 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'; // Default: Indigo to Violet
}
function getLifeYears(): string | null {
if (!author.lifespan) return null;
const birth = author.lifespan.birth?.substring(0, 4);
const death = author.lifespan.death?.substring(0, 4);
if (birth && death) {
return `${birth} ${death}`;
}
if (birth) {
return `Born ${birth}`;
}
return null;
}
function handleCopy() {
const lifeYears = getLifeYears();
const text = `${author.name}${lifeYears ? ` (${lifeYears})` : ''}\n${author.profession?.join(', ') || ''}`;
navigator.clipboard.writeText(text);
dispatch('copy', { author });
showCopyFeedback();
}
function handleShare() {
const lifeYears = getLifeYears();
const authorInfo = `${author.name}${lifeYears ? ` (${lifeYears})` : ''}\n${author.profession?.join(', ') || ''}\n\n${author.biography?.short || ''}`;
if (navigator.share) {
navigator
.share({
title: author.name,
text: authorInfo,
})
.catch(() => {
handleCopy();
});
} else {
handleCopy();
}
dispatch('share', { author });
}
function handleFavorite() {
dispatch('toggleFavorite', { authorId: author.id });
}
function handleClick(e: MouseEvent) {
// Only navigate if not clicking action buttons
if (!(e.target as HTMLElement).closest('.action-btn')) {
dispatch('click', { author });
}
}
let showCopySuccess = $state(false);
function showCopyFeedback() {
showCopySuccess = true;
setTimeout(() => {
showCopySuccess = false;
}, 2000);
}
const gradientStyle = getGradientColors(author);
const lifeYears = getLifeYears();
</script>
<article
class="author-card"
class:enhanced={variant === 'enhanced'}
class:simple={variant === 'simple'}
style="background: {gradientStyle}"
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={(e) => e.key === 'Enter' && handleClick(e as any)}
>
<div class="card-inner">
<!-- Main Content -->
<div class="author-header">
<!-- Avatar -->
<div class="author-avatar">
{author.name.charAt(0)}
</div>
<!-- Author Info -->
<div class="author-info">
<h3 class="author-name">{author.name}</h3>
{#if lifeYears}
<p class="lifespan">{lifeYears}</p>
{/if}
</div>
<!-- Arrow -->
<div class="arrow">
<svg
xmlns="http://www.w3.org/2000/svg"
width="25"
height="25"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</div>
</div>
<!-- Biography -->
{#if author.biography?.short}
<div class="bio-section">
<p class="bio">{author.biography.short}</p>
</div>
{/if}
<!-- Professions and Action Buttons -->
<div class="footer-section">
<!-- Professions -->
<div class="professions">
{#if author.profession && author.profession.length > 0}
{#each author.profession.slice(0, 2) as profession}
<span class="profession-tag">{profession}</span>
{/each}
{#if author.profession.length > 2}
<span class="profession-more">+{author.profession.length - 2}</span>
{/if}
{/if}
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<!-- Copy Button -->
<button
class="action-btn"
onclick={(e) => {
e.stopPropagation();
handleCopy();
}}
title="Copy author info"
aria-label="Copy author info to clipboard"
>
{#if showCopySuccess}
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
{/if}
</button>
<!-- Share Button -->
<button
class="action-btn"
onclick={(e) => {
e.stopPropagation();
handleShare();
}}
title="Share author"
aria-label="Share author"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
</button>
<!-- Favorite Button -->
<button
class="action-btn favorite-btn"
class:is-favorite={isFavorite}
onclick={(e) => {
e.stopPropagation();
handleFavorite();
}}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
{#if isFavorite}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
></path>
</svg>
{/if}
</button>
</div>
</div>
</div>
</article>
<style>
.author-card {
position: relative;
border-radius: 24px;
padding: 1.5px;
transition:
transform var(--transition-base),
box-shadow var(--transition-base);
cursor: pointer;
}
.author-card:hover {
transform: scale(0.98);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
}
.card-inner {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: calc(24px - 1.5px);
padding: var(--spacing-lg);
}
/* Author Header */
.author-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.author-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
font-weight: bold;
flex-shrink: 0;
}
.author-info {
flex: 1;
min-width: 0;
}
.author-name {
margin: 0 0 4px 0;
font-size: 1.125rem;
font-weight: 500;
color: white;
}
.lifespan {
margin: 0;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
}
.arrow {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
/* Bio Section */
.bio-section {
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: var(--spacing-md);
padding-top: var(--spacing-sm);
}
.bio {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.7);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Footer Section */
.footer-section {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: var(--spacing-sm);
gap: var(--spacing-md);
}
.professions {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
flex: 1;
min-width: 0;
}
.profession-tag {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(255, 255, 255, 0.1);
color: white;
border-radius: var(--radius-full);
font-size: 0.75rem;
opacity: 0.7;
white-space: nowrap;
}
.profession-more {
align-self: center;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: var(--spacing-sm);
align-items: center;
flex-shrink: 0;
}
.action-btn {
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgba(255, 255, 255, 0.7);
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.action-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
.action-btn:active {
transform: scale(0.95);
}
.favorite-btn.is-favorite {
color: #ff6b9d;
}
.favorite-btn.is-favorite:hover {
color: #ff4081;
}
/* Responsive */
@media (max-width: 768px) {
.author-avatar {
width: 48px;
height: 48px;
font-size: 1.25rem;
}
.author-name {
font-size: 1rem;
}
.action-btn svg {
width: 20px;
height: 20px;
}
}
</style>

View file

@ -0,0 +1,52 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
const languageLabels: Record<string, string> = {
de: 'Deutsch',
en: 'English',
};
let isOpen = $state(false);
function handleSelect(lang: string) {
setLocale(lang as 'de' | 'en');
isOpen = false;
}
</script>
<div class="relative">
<button
onclick={() => (isOpen = !isOpen)}
class="flex items-center gap-1 px-3 py-2 rounded-lg text-sm text-foreground-secondary hover:text-foreground hover:bg-surface transition-colors"
>
{languageLabels[$locale || 'de']}
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isOpen}
<div
class="absolute right-0 mt-1 py-1 bg-surface-elevated border border-border rounded-lg shadow-lg z-50"
>
{#each supportedLocales as lang}
<button
onclick={() => handleSelect(lang)}
class="w-full px-4 py-2 text-left text-sm hover:bg-surface transition-colors"
class:text-primary={$locale === lang}
>
{languageLabels[lang]}
</button>
{/each}
</div>
{/if}
</div>
{#if isOpen}
<button
class="fixed inset-0 z-40 bg-transparent"
onclick={() => (isOpen = false)}
aria-label="Close menu"
></button>
{/if}

View file

@ -0,0 +1,155 @@
<script lang="ts">
import type { Quote, Category } from '@zitare/content';
import { quotesStore } from '$lib/stores/quotes.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { _ } from 'svelte-i18n';
interface Props {
quote: Quote;
showCategory?: boolean;
showSource?: boolean;
size?: 'small' | 'medium' | 'large';
}
let { quote, showCategory = false, showSource = true, size = 'medium' }: Props = $props();
let isFavorite = $derived(favoritesStore.isFavorite(quote.id));
let quoteText = $derived(quotesStore.getText(quote));
// Category gradient classes
const categoryGradients: Record<Category, string> = {
weisheit: 'quote-gradient-wisdom',
motivation: 'quote-gradient-motivation',
liebe: 'quote-gradient-love',
leben: 'quote-gradient-life',
erfolg: 'quote-gradient-success',
glueck: 'quote-gradient-happiness',
freundschaft: 'quote-gradient-friendship',
mut: 'quote-gradient-courage',
hoffnung: 'quote-gradient-hope',
natur: 'quote-gradient-nature',
};
// Category labels
const categoryLabels: Record<Category, string> = {
weisheit: 'categories.wisdom',
motivation: 'categories.motivation',
liebe: 'categories.love',
leben: 'categories.life',
erfolg: 'categories.success',
glueck: 'categories.happiness',
freundschaft: 'categories.friendship',
mut: 'categories.courage',
hoffnung: 'categories.hope',
natur: 'categories.nature',
};
async function toggleFavorite() {
if (!authStore.isAuthenticated) return;
await favoritesStore.toggle(quote.id);
}
async function shareQuote() {
const text = `"${quoteText}" — ${quote.author}`;
if (navigator.share) {
await navigator.share({
text,
title: 'Zitare',
});
} else {
await navigator.clipboard.writeText(text);
}
}
const sizeClasses = {
small: 'p-4 text-base',
medium: 'p-6 text-lg',
large: 'p-8 text-xl md:text-2xl',
};
</script>
<div
class="quote-card rounded-2xl bg-surface-elevated shadow-lg overflow-hidden {sizeClasses[size]}"
>
{#if showCategory}
<div class="mb-4">
<span
class="inline-block px-3 py-1 rounded-full text-sm font-medium text-white {categoryGradients[
quote.category
]}"
>
{$_(categoryLabels[quote.category])}
</span>
</div>
{/if}
<blockquote class="quote-text text-foreground mb-4">
"{quoteText}"
</blockquote>
<div class="flex items-center justify-between">
<div>
<p class="quote-author text-foreground-secondary">{quote.author}</p>
{#if showSource && (quote.source || quote.year)}
<p class="text-sm text-foreground-muted mt-1">
{#if quote.source}
{quote.source}
{/if}
{#if quote.source && quote.year}
·
{/if}
{#if quote.year}
{quote.year}
{/if}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<button
onclick={shareQuote}
class="p-2 rounded-full hover:bg-surface-hover transition-colors"
aria-label={$_('home.share')}
>
<svg
class="w-5 h-5 text-foreground-secondary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
</button>
{#if authStore.isAuthenticated}
<button
onclick={toggleFavorite}
class="p-2 rounded-full hover:bg-surface-hover transition-colors"
aria-label={isFavorite ? $_('home.unfavorite') : $_('home.favorite')}
>
<svg
class="w-5 h-5 transition-colors {isFavorite
? 'text-red-500 fill-red-500'
: 'text-foreground-secondary'}"
fill={isFavorite ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</button>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,49 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('zitare_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('zitare_locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,73 @@
{
"app": {
"name": "Zitare",
"tagline": "Inspirierende Zitate jeden Tag"
},
"nav": {
"home": "Heute",
"categories": "Kategorien",
"favorites": "Favoriten",
"lists": "Listen",
"search": "Suche",
"settings": "Einstellungen",
"feedback": "Feedback"
},
"home": {
"dailyQuote": "Zitat des Tages",
"newQuote": "Neues Zitat",
"share": "Teilen",
"favorite": "Favorit",
"unfavorite": "Entfernen",
"source": "Quelle",
"year": "Jahr"
},
"categories": {
"title": "Kategorien",
"wisdom": "Weisheit",
"motivation": "Motivation",
"love": "Liebe",
"life": "Leben",
"success": "Erfolg",
"happiness": "Glück",
"friendship": "Freundschaft",
"courage": "Mut",
"hope": "Hoffnung",
"nature": "Natur",
"quotes": "{count} Zitate"
},
"favorites": {
"title": "Favoriten",
"empty": "Noch keine Favoriten",
"emptyDescription": "Tippe auf das Herz-Symbol, um Zitate zu speichern"
},
"lists": {
"title": "Meine Listen",
"create": "Neue Liste",
"empty": "Noch keine Listen",
"emptyDescription": "Erstelle Listen, um Zitate zu organisieren"
},
"search": {
"title": "Suche",
"placeholder": "Zitat oder Autor suchen...",
"noResults": "Keine Ergebnisse",
"results": "{count} Ergebnisse"
},
"auth": {
"login": "Anmelden",
"logout": "Abmelden",
"register": "Registrieren"
},
"feedback": {
"title": "Feedback & Vorschläge",
"subtitle": "Teile deine Ideen und stimme für Feature-Wünsche ab"
},
"common": {
"loading": "Laden...",
"error": "Ein Fehler ist aufgetreten",
"retry": "Erneut versuchen",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten"
}
}

View file

@ -0,0 +1,73 @@
{
"app": {
"name": "Zitare",
"tagline": "Inspiring quotes every day"
},
"nav": {
"home": "Today",
"categories": "Categories",
"favorites": "Favorites",
"lists": "Lists",
"search": "Search",
"settings": "Settings",
"feedback": "Feedback"
},
"home": {
"dailyQuote": "Quote of the Day",
"newQuote": "New Quote",
"share": "Share",
"favorite": "Favorite",
"unfavorite": "Remove",
"source": "Source",
"year": "Year"
},
"categories": {
"title": "Categories",
"wisdom": "Wisdom",
"motivation": "Motivation",
"love": "Love",
"life": "Life",
"success": "Success",
"happiness": "Happiness",
"friendship": "Friendship",
"courage": "Courage",
"hope": "Hope",
"nature": "Nature",
"quotes": "{count} quotes"
},
"favorites": {
"title": "Favorites",
"empty": "No favorites yet",
"emptyDescription": "Tap the heart icon to save quotes"
},
"lists": {
"title": "My Lists",
"create": "New List",
"empty": "No lists yet",
"emptyDescription": "Create lists to organize quotes"
},
"search": {
"title": "Search",
"placeholder": "Search quotes or authors...",
"noResults": "No results",
"results": "{count} results"
},
"auth": {
"login": "Sign In",
"logout": "Sign Out",
"register": "Sign Up"
},
"feedback": {
"title": "Feedback & Suggestions",
"subtitle": "Share your ideas and vote for feature requests"
},
"common": {
"loading": "Loading...",
"error": "An error occurred",
"retry": "Try again",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit"
}
}

View file

@ -241,4 +241,28 @@ export const authStore = {
}
return await tokenManager.getValidToken();
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to send verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -0,0 +1,146 @@
/**
* Favorites Store - Manages user's favorite quotes
*/
import { browser } from '$app/environment';
import { authStore } from './auth.svelte';
interface Favorite {
id: string;
quoteId: string;
createdAt: string;
}
// State
let favorites = $state<Favorite[]>([]);
let loading = $state(false);
let initialized = $state(false);
// Get backend URL
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3007';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3007';
}
async function fetchWithAuth(path: string, options: RequestInit = {}) {
const token = await authStore.getValidToken();
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${getBackendUrl()}/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
export const favoritesStore = {
get favorites() {
return favorites;
},
get loading() {
return loading;
},
get initialized() {
return initialized;
},
/**
* Check if a quote is favorited
*/
isFavorite(quoteId: string): boolean {
return favorites.some((f) => f.quoteId === quoteId);
},
/**
* Load favorites from backend
*/
async load() {
if (!authStore.isAuthenticated) {
favorites = [];
initialized = true;
return;
}
loading = true;
try {
const data = await fetchWithAuth('/favorites');
favorites = data.favorites || [];
initialized = true;
} catch (error) {
console.error('Failed to load favorites:', error);
favorites = [];
} finally {
loading = false;
}
},
/**
* Add a quote to favorites
*/
async add(quoteId: string) {
if (!authStore.isAuthenticated) return;
try {
const data = await fetchWithAuth('/favorites', {
method: 'POST',
body: JSON.stringify({ quoteId }),
});
favorites = [...favorites, data.favorite];
} catch (error) {
console.error('Failed to add favorite:', error);
throw error;
}
},
/**
* Remove a quote from favorites
*/
async remove(quoteId: string) {
if (!authStore.isAuthenticated) return;
try {
await fetchWithAuth(`/favorites/${quoteId}`, {
method: 'DELETE',
});
favorites = favorites.filter((f) => f.quoteId !== quoteId);
} catch (error) {
console.error('Failed to remove favorite:', error);
throw error;
}
},
/**
* Toggle favorite status
*/
async toggle(quoteId: string) {
if (this.isFavorite(quoteId)) {
await this.remove(quoteId);
} else {
await this.add(quoteId);
}
},
/**
* Clear all favorites (client-side only)
*/
clear() {
favorites = [];
initialized = false;
},
};

View file

@ -0,0 +1,203 @@
// Lists store - integrates with Zitare backend API
import { browser } from '$app/environment';
import { authStore } from './auth.svelte';
const API_URL = browser
? import.meta.env.PUBLIC_ZITARE_API_URL || 'http://localhost:3007'
: 'http://localhost:3007';
export interface QuoteList {
id: string;
name: string;
description?: string;
quoteIds: string[];
createdAt: string;
updatedAt: string;
}
let lists = $state<QuoteList[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
const token = await authStore.getValidToken();
if (!token) {
throw new Error('Not authenticated');
}
return fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
async function loadLists() {
if (!authStore.isAuthenticated) {
lists = [];
return;
}
isLoading = true;
error = null;
try {
const response = await fetchWithAuth(`${API_URL}/lists`);
if (!response.ok) {
throw new Error('Failed to load lists');
}
const data = await response.json();
lists = data.lists || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load lists';
lists = [];
} finally {
isLoading = false;
}
}
async function getList(id: string): Promise<QuoteList | null> {
if (!authStore.isAuthenticated) {
return null;
}
try {
const response = await fetchWithAuth(`${API_URL}/lists/${id}`);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.list || null;
} catch {
return null;
}
}
async function createList(name: string, description?: string): Promise<QuoteList | null> {
if (!authStore.isAuthenticated) {
return null;
}
try {
const response = await fetchWithAuth(`${API_URL}/lists`, {
method: 'POST',
body: JSON.stringify({ name, description }),
});
if (!response.ok) {
throw new Error('Failed to create list');
}
const data = await response.json();
const newList = data.list;
lists = [...lists, newList];
return newList;
} catch {
return null;
}
}
async function updateList(
id: string,
updates: { name?: string; description?: string }
): Promise<QuoteList | null> {
if (!authStore.isAuthenticated) {
return null;
}
try {
const response = await fetchWithAuth(`${API_URL}/lists/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error('Failed to update list');
}
const data = await response.json();
const updatedList = data.list;
lists = lists.map((l) => (l.id === id ? updatedList : l));
return updatedList;
} catch {
return null;
}
}
async function deleteList(id: string): Promise<boolean> {
if (!authStore.isAuthenticated) {
return false;
}
try {
const response = await fetchWithAuth(`${API_URL}/lists/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete list');
}
lists = lists.filter((l) => l.id !== id);
return true;
} catch {
return false;
}
}
async function addQuoteToList(listId: string, quoteId: string): Promise<boolean> {
if (!authStore.isAuthenticated) {
return false;
}
try {
const response = await fetchWithAuth(`${API_URL}/lists/${listId}/quotes`, {
method: 'POST',
body: JSON.stringify({ quoteId }),
});
if (!response.ok) {
throw new Error('Failed to add quote to list');
}
const data = await response.json();
lists = lists.map((l) => (l.id === listId ? data.list : l));
return true;
} catch {
return false;
}
}
async function removeQuoteFromList(listId: string, quoteId: string): Promise<boolean> {
if (!authStore.isAuthenticated) {
return false;
}
try {
const response = await fetchWithAuth(`${API_URL}/lists/${listId}/quotes/${quoteId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to remove quote from list');
}
const data = await response.json();
lists = lists.map((l) => (l.id === listId ? data.list : l));
return true;
} catch {
return false;
}
}
export const listsStore = {
get lists() {
return lists;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
loadLists,
getList,
createList,
updateList,
deleteList,
addQuoteToList,
removeQuoteFromList,
};

View file

@ -0,0 +1,117 @@
/**
* Quotes Store - Manages quote display state
*/
import { browser } from '$app/environment';
import {
QUOTES,
getDailyQuote,
getRandomQuote,
getQuotesByCategory,
searchQuotes,
getQuoteText,
type Quote,
type Category,
type SupportedLanguage,
} from '@zitare/content';
// State
let currentQuote = $state<Quote | null>(null);
let language = $state<SupportedLanguage>('de');
// Get stored language or detect from browser
function getInitialLanguage(): SupportedLanguage {
if (browser) {
const stored = localStorage.getItem('zitare_quote_language');
if (stored && ['de', 'en', 'it', 'fr', 'es', 'original'].includes(stored)) {
return stored as SupportedLanguage;
}
// Map browser language to supported language
const browserLang = navigator.language.split('-')[0];
const langMap: Record<string, SupportedLanguage> = {
de: 'de',
en: 'en',
it: 'it',
fr: 'fr',
es: 'es',
};
return langMap[browserLang] || 'de';
}
return 'de';
}
export const quotesStore = {
get currentQuote() {
return currentQuote;
},
get language() {
return language;
},
get allQuotes() {
return QUOTES;
},
get totalCount() {
return QUOTES.length;
},
/**
* Initialize the store
*/
initialize() {
language = getInitialLanguage();
currentQuote = getDailyQuote();
},
/**
* Set the display language
*/
setLanguage(lang: SupportedLanguage) {
language = lang;
if (browser) {
localStorage.setItem('zitare_quote_language', lang);
}
},
/**
* Get quote text in current language
*/
getText(quote: Quote): string {
return getQuoteText(quote, language);
},
/**
* Load the daily quote
*/
loadDailyQuote() {
currentQuote = getDailyQuote();
},
/**
* Load a random quote
*/
loadRandomQuote() {
currentQuote = getRandomQuote();
},
/**
* Get quotes by category
*/
getByCategory(category: Category): Quote[] {
return getQuotesByCategory(category);
},
/**
* Search quotes
*/
search(query: string): Quote[] {
return searchQuotes(query, language);
},
/**
* Set current quote
*/
setCurrentQuote(quote: Quote) {
currentQuote = quote;
},
};

View file

@ -0,0 +1,96 @@
/**
* Theme Store - Manages theme state using Svelte 5 runes
*/
import { browser } from '$app/environment';
import type { ThemeMode, ThemeVariant } from '@manacore/shared-theme';
// State
let mode = $state<ThemeMode>('system');
let variant = $state<ThemeVariant>('lume');
let initialized = $state(false);
// Derived state
let isDark = $derived(
mode === 'system'
? browser
? window.matchMedia('(prefers-color-scheme: dark)').matches
: true // Default to dark for SSR
: mode === 'dark'
);
function applyTheme() {
if (!browser) return;
const root = document.documentElement;
// Apply dark/light mode
if (isDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Apply variant
root.setAttribute('data-theme', variant);
}
export const theme = {
get mode() {
return mode;
},
get variant() {
return variant;
},
get isDark() {
return isDark;
},
get initialized() {
return initialized;
},
initialize() {
if (!browser || initialized) return;
// Load from localStorage
const savedMode = localStorage.getItem('zitare-theme-mode') as ThemeMode | null;
const savedVariant = localStorage.getItem('zitare-theme-variant') as ThemeVariant | null;
if (savedMode) mode = savedMode;
if (savedVariant) variant = savedVariant;
// Apply initial theme
applyTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (mode === 'system') {
applyTheme();
}
});
initialized = true;
},
setMode(newMode: ThemeMode) {
mode = newMode;
if (browser) {
localStorage.setItem('zitare-theme-mode', newMode);
applyTheme();
}
},
setVariant(newVariant: ThemeVariant) {
variant = newVariant;
if (browser) {
localStorage.setItem('zitare-theme-variant', newVariant);
applyTheme();
}
},
toggleMode() {
const newMode = isDark ? 'light' : 'dark';
this.setMode(newMode);
},
};

View file

@ -0,0 +1,33 @@
// Simple toast notification store
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
id: string;
message: string;
type: ToastType;
}
let toasts = $state<Toast[]>([]);
function addToast(message: string, type: ToastType = 'info', duration = 3000) {
const id = crypto.randomUUID();
toasts = [...toasts, { id, message, type }];
setTimeout(() => {
toasts = toasts.filter((t) => t.id !== id);
}, duration);
}
export const toast = {
get toasts() {
return toasts;
},
success: (message: string) => addToast(message, 'success'),
error: (message: string) => addToast(message, 'error'),
info: (message: string) => addToast(message, 'info'),
warning: (message: string) => addToast(message, 'warning'),
dismiss: (id: string) => {
toasts = toasts.filter((t) => t.id !== id);
},
};

View file

@ -0,0 +1,217 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale, _ } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
// App switcher items
const appItems = getPillAppItems('zitare');
let { children } = $props();
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Visible themes in PillNav
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant]?.label || variant,
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
onClick: () => theme.setVariant(variant),
active: (theme.variant || 'lume') === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as 'de' | 'en');
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Zitare
const navItems: PillNavItem[] = [
{ href: '/', label: 'Heute', icon: 'sun' },
{ href: '/categories', label: 'Kategorien', icon: 'grid' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/lists', label: 'Listen', icon: 'list' },
{ href: '/search', label: 'Suche', icon: 'search' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('zitare-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('zitare-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
favoritesStore.clear();
goto('/login');
}
onMount(async () => {
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('zitare-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('zitare-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
}
// Load favorites if authenticated
if (authStore.isAuthenticated) {
await favoritesStore.load();
}
});
</script>
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Zitare"
homeRoute="/"
desktopPosition="bottom"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
transition: all 300ms ease;
position: relative;
z-index: 0;
}
.main-content.floating-mode {
padding-top: 70px;
}
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 1rem;
position: relative;
z-index: 0;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
</style>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { quotesStore } from '$lib/stores/quotes.svelte';
import QuoteCard from '$lib/components/QuoteCard.svelte';
let isRefreshing = $state(false);
async function loadNewQuote() {
isRefreshing = true;
quotesStore.loadRandomQuote();
// Small delay for visual feedback
await new Promise((r) => setTimeout(r, 300));
isRefreshing = false;
}
</script>
<svelte:head>
<title>Zitare - {$_('home.dailyQuote')}</title>
</svelte:head>
<div class="max-w-2xl mx-auto">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-foreground mb-2">{$_('home.dailyQuote')}</h1>
<p class="text-foreground-secondary">{$_('app.tagline')}</p>
</div>
<!-- Daily Quote Card -->
{#if quotesStore.currentQuote}
<div class="mb-8 transition-all duration-300 {isRefreshing ? 'opacity-50 scale-95' : ''}">
<QuoteCard quote={quotesStore.currentQuote} size="large" showCategory showSource />
</div>
{/if}
<!-- New Quote Button -->
<div class="text-center">
<button
onclick={loadNewQuote}
disabled={isRefreshing}
class="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors disabled:opacity-50"
>
<svg
class="w-5 h-5 {isRefreshing ? 'animate-spin' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{$_('home.newQuote')}
</button>
</div>
<!-- Quote Stats -->
<div class="mt-12 text-center">
<p class="text-sm text-foreground-muted">
{quotesStore.totalCount} Zitate in 10 Kategorien
</p>
</div>
</div>

View file

@ -1,448 +0,0 @@
<script lang="ts">
import { authorsDE, quotesDE } from '@zitare/shared';
import type { Author } from '@zitare/shared';
import { PageHeader } from '@manacore/shared-ui';
import AuthorCard from '$lib/components/AuthorCard.svelte';
// Get quote counts for each author
const authorsWithQuotes = authorsDE
.map((author) => {
const quoteCount = quotesDE.filter((q) => q.authorId === author.id).length;
return { ...author, quoteCount };
})
.sort((a, b) => b.quoteCount - a.quoteCount);
let searchTerm = $state('');
let favorites = $state<Set<string>>(new Set());
let isSearchOpen = $state(false);
// Pagination state
const ITEMS_PER_PAGE = 20;
let currentPage = $state(1);
let isLoadingMore = $state(false);
// Load favorites from localStorage
if (typeof window !== 'undefined') {
const savedFavorites = localStorage.getItem('authorFavorites');
if (savedFavorites) {
favorites = new Set(JSON.parse(savedFavorites));
}
}
// Filter authors by search term (all matching authors)
let allFilteredAuthors = $derived(
authorsWithQuotes
.map((author) => ({
...author,
isFavorite: favorites.has(author.id),
}))
.filter(
(author) =>
author.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
author.profession?.some((p) => p.toLowerCase().includes(searchTerm.toLowerCase()))
)
);
// Paginated authors (only show what should be visible)
let filteredAuthors = $derived(allFilteredAuthors.slice(0, currentPage * ITEMS_PER_PAGE));
// Check if there are more items to load
let hasMore = $derived(filteredAuthors.length < allFilteredAuthors.length);
function toggleSearch() {
isSearchOpen = !isSearchOpen;
if (!isSearchOpen) {
searchTerm = '';
currentPage = 1;
}
}
function loadMore() {
isLoadingMore = true;
setTimeout(() => {
currentPage++;
isLoadingMore = false;
}, 300);
}
// Reset page when search changes
$effect(() => {
searchTerm;
currentPage = 1;
});
function handleToggleFavorite(event: CustomEvent) {
const { authorId } = event.detail;
if (favorites.has(authorId)) {
favorites.delete(authorId);
} else {
favorites.add(authorId);
}
favorites = new Set(favorites);
// Save to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('authorFavorites', JSON.stringify([...favorites]));
}
}
function handleAuthorClick(event: CustomEvent) {
const { author } = event.detail;
window.location.href = `/authors/${author.id}`;
}
</script>
<svelte:head>
<title>Autoren - Zitare</title>
<meta name="description" content="Durchsuche alle Autoren und ihre Zitate" />
</svelte:head>
<div class="authors-page">
<div class="header-container">
<PageHeader title="Autoren" size="lg">
{#snippet actions()}
<button class="search-fab" onclick={toggleSearch} aria-label="Toggle search">
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{#if isSearchOpen}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
{/if}
</svg>
</button>
{/snippet}
</PageHeader>
{#if isSearchOpen}
<div class="search-bar">
<input type="text" placeholder="Search authors..." bind:value={searchTerm} class="search" />
</div>
{/if}
</div>
{#if allFilteredAuthors.length === 0 && searchTerm}
<!-- Empty Search Results -->
<div class="empty-state">
<div class="empty-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<h3>Keine Autoren gefunden</h3>
<p>Versuche es mit anderen Suchbegriffen</p>
</div>
{:else}
<div class="authors-grid">
{#each filteredAuthors as author (author.id)}
<AuthorCard
{author}
isFavorite={author.isFavorite}
on:click={handleAuthorClick}
on:toggleFavorite={handleToggleFavorite}
/>
{/each}
</div>
<!-- Load More Button -->
{#if hasMore}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{#if isLoadingMore}
<svg
class="spinner"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-opacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10" stroke-width="3" stroke-linecap="round"></path>
</svg>
Laden...
{:else}
Mehr laden ({allFilteredAuthors.length - filteredAuthors.length} weitere)
{/if}
</button>
</div>
{/if}
{/if}
{#if isSearchOpen}
<div class="floating-results">
{allFilteredAuthors.length} von {authorsDE.length} Autoren
{#if filteredAuthors.length < allFilteredAuthors.length}
{filteredAuthors.length} angezeigt
{/if}
</div>
{/if}
</div>
<style>
.authors-page {
max-width: 1200px;
margin: 0 auto;
position: relative;
padding-bottom: var(--spacing-2xl);
}
.header-container {
max-width: 700px;
margin: 0 auto var(--spacing-xl);
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
h2 {
font-size: 2rem;
margin: 0;
color: rgb(var(--color-text-primary));
}
.search-fab {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 9999px;
background: rgb(var(--color-primary));
color: white;
border: none;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
}
.search-fab:hover {
transform: scale(1.05);
box-shadow: var(--shadow-lg);
}
.search-fab:active {
transform: scale(0.95);
}
.search-bar {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background: rgb(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid rgb(var(--color-border));
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-background));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
}
.search:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.authors-grid {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
max-width: 700px;
margin: 0 auto;
}
.floating-results {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: var(--spacing-sm) var(--spacing-lg);
background: rgba(var(--color-surface), 0.95);
backdrop-filter: blur(10px);
border-radius: var(--radius-full);
border: 1px solid rgba(var(--color-border), 0.5);
box-shadow: var(--shadow-lg);
color: rgb(var(--color-text-secondary));
font-size: 0.875rem;
font-weight: 500;
z-index: 20;
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translate(-50%, 10px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* Empty State */
.empty-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.empty-icon {
margin: 0 auto var(--spacing-lg);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-sm) 0;
}
.empty-state p {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0;
}
/* Load More Button */
.load-more-container {
max-width: 700px;
margin: var(--spacing-xl) auto 0;
text-align: center;
}
.load-more-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-2xl);
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-full);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-base);
}
.load-more-btn:hover:not(:disabled) {
background: rgb(var(--color-primary));
color: white;
border-color: rgb(var(--color-primary));
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.load-more-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.authors-page {
padding-bottom: var(--spacing-xl);
}
.header-container {
max-width: 100%;
margin-bottom: var(--spacing-lg);
}
.header-row {
margin-bottom: var(--spacing-md);
}
h2 {
font-size: 1.5rem;
}
.search-fab {
width: 2.5rem;
height: 2.5rem;
}
.search-bar {
padding: var(--spacing-sm);
}
.authors-grid {
gap: var(--spacing-lg);
max-width: 100%;
}
.floating-results {
bottom: 5rem; /* Above mobile bottom nav */
font-size: 0.8125rem;
padding: var(--spacing-xs) var(--spacing-md);
}
.empty-state {
padding: var(--spacing-xl);
}
.empty-state h3 {
font-size: 1.25rem;
}
.empty-state p {
font-size: 0.9375rem;
}
}
</style>

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { CATEGORIES, getQuotesByCategory, type Category } from '@zitare/content';
// Category data with icons and gradients
const categoryData: Record<
Category,
{ icon: string; gradient: string; labelKey: string; count: number }
> = {
weisheit: {
icon: '🧠',
gradient: 'from-violet-500 to-purple-600',
labelKey: 'categories.wisdom',
count: getQuotesByCategory('weisheit').length,
},
motivation: {
icon: '🔥',
gradient: 'from-orange-500 to-red-500',
labelKey: 'categories.motivation',
count: getQuotesByCategory('motivation').length,
},
liebe: {
icon: '❤️',
gradient: 'from-pink-500 to-rose-500',
labelKey: 'categories.love',
count: getQuotesByCategory('liebe').length,
},
leben: {
icon: '🌱',
gradient: 'from-emerald-500 to-cyan-500',
labelKey: 'categories.life',
count: getQuotesByCategory('leben').length,
},
erfolg: {
icon: '🏆',
gradient: 'from-indigo-500 to-purple-500',
labelKey: 'categories.success',
count: getQuotesByCategory('erfolg').length,
},
glueck: {
icon: '☀️',
gradient: 'from-yellow-400 to-orange-500',
labelKey: 'categories.happiness',
count: getQuotesByCategory('glueck').length,
},
freundschaft: {
icon: '🤝',
gradient: 'from-blue-500 to-indigo-500',
labelKey: 'categories.friendship',
count: getQuotesByCategory('freundschaft').length,
},
mut: {
icon: '🦁',
gradient: 'from-red-500 to-red-700',
labelKey: 'categories.courage',
count: getQuotesByCategory('mut').length,
},
hoffnung: {
icon: '🌈',
gradient: 'from-teal-500 to-sky-500',
labelKey: 'categories.hope',
count: getQuotesByCategory('hoffnung').length,
},
natur: {
icon: '🌿',
gradient: 'from-green-500 to-emerald-500',
labelKey: 'categories.nature',
count: getQuotesByCategory('natur').length,
},
};
</script>
<svelte:head>
<title>Zitare - {$_('categories.title')}</title>
</svelte:head>
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-8">{$_('categories.title')}</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each CATEGORIES as category}
{@const data = categoryData[category]}
<button
onclick={() => goto(`/category/${category}`)}
class="group p-6 rounded-2xl bg-gradient-to-br {data.gradient} text-white text-left transition-transform hover:scale-105 hover:shadow-xl"
>
<div class="text-4xl mb-3">{data.icon}</div>
<h2 class="text-xl font-semibold mb-1">{$_(data.labelKey)}</h2>
<p class="text-white/80 text-sm">
{$_('categories.quotes', { values: { count: data.count } })}
</p>
</button>
{/each}
</div>
</div>

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getQuotesByCategory, CATEGORIES, type Category } from '@zitare/content';
import QuoteCard from '$lib/components/QuoteCard.svelte';
// Get category from URL
let category = $derived($page.params.category as Category);
// Validate category
let isValidCategory = $derived(CATEGORIES.includes(category));
// Get quotes for this category
let quotes = $derived(isValidCategory ? getQuotesByCategory(category) : []);
// Category labels
const categoryLabels: Record<Category, string> = {
weisheit: 'categories.wisdom',
motivation: 'categories.motivation',
liebe: 'categories.love',
leben: 'categories.life',
erfolg: 'categories.success',
glueck: 'categories.happiness',
freundschaft: 'categories.friendship',
mut: 'categories.courage',
hoffnung: 'categories.hope',
natur: 'categories.nature',
};
</script>
<svelte:head>
<title>Zitare - {isValidCategory ? $_(categoryLabels[category]) : 'Kategorie'}</title>
</svelte:head>
<div class="max-w-3xl mx-auto">
<!-- Back button -->
<button
onclick={() => goto('/categories')}
class="flex items-center gap-2 text-foreground-secondary hover:text-foreground mb-6 transition-colors"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{$_('categories.title')}
</button>
{#if isValidCategory}
<h1 class="text-3xl font-bold text-foreground mb-2">{$_(categoryLabels[category])}</h1>
<p class="text-foreground-secondary mb-8">
{$_('categories.quotes', { values: { count: quotes.length } })}
</p>
<div class="space-y-6">
{#each quotes as quote (quote.id)}
<QuoteCard {quote} showSource />
{/each}
</div>
{:else}
<div class="text-center py-12">
<p class="text-foreground-secondary">Kategorie nicht gefunden</p>
<button onclick={() => goto('/categories')} class="mt-4 text-primary hover:underline">
Zurück zu Kategorien
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { getQuoteById } from '@zitare/content';
import QuoteCard from '$lib/components/QuoteCard.svelte';
// Get favorite quotes
let favoriteQuotes = $derived(
favoritesStore.favorites
.map((f) => getQuoteById(f.quoteId))
.filter((q): q is NonNullable<typeof q> => q !== undefined)
);
</script>
<svelte:head>
<title>Zitare - {$_('favorites.title')}</title>
</svelte:head>
<div class="max-w-3xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-8">{$_('favorites.title')}</h1>
{#if !authStore.isAuthenticated}
<!-- Not logged in -->
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<svg
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<p class="text-foreground-secondary mb-4">Melde dich an, um Favoriten zu speichern</p>
<button
onclick={() => goto('/login')}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
{$_('auth.login')}
</button>
</div>
{:else if favoritesStore.loading}
<!-- Loading -->
<div class="text-center py-12">
<div
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
{:else if favoriteQuotes.length === 0}
<!-- Empty state -->
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<svg
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<p class="text-lg font-medium text-foreground mb-2">{$_('favorites.empty')}</p>
<p class="text-foreground-secondary">{$_('favorites.emptyDescription')}</p>
</div>
{:else}
<!-- Favorites list -->
<div class="space-y-6">
{#each favoriteQuotes as quote (quote.id)}
<QuoteCard {quote} showCategory showSource />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
const feedbackService = createFeedbackService({
apiUrl: import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001',
appId: 'zitare',
getAuthToken: () => authStore.getValidToken(),
});
</script>
<svelte:head>
<title>Zitare - {$_('nav.feedback')}</title>
</svelte:head>
<FeedbackPage
{feedbackService}
appName="Zitare"
currentUserId={authStore.user?.id}
pageTitle={$_('feedback.title')}
pageSubtitle={$_('feedback.subtitle')}
/>

View file

@ -1,132 +1,130 @@
<script lang="ts">
import { listsStore } from '$lib/stores/lists';
import type { QuoteList } from '$lib/stores/lists';
import { quotesDE } from '@zitare/shared';
import { PageHeader } from '@manacore/shared-ui';
import { toast } from '$lib/stores/toast';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
interface QuoteList {
id: string;
name: string;
description?: string;
quoteIds: string[];
createdAt: string;
updatedAt: string;
}
let lists = $state<QuoteList[]>([]);
let loading = $state(true);
let showCreateModal = $state(false);
let newListName = $state('');
let newListDescription = $state('');
let searchTerm = $state('');
// Subscribe to lists store
listsStore.subscribe((value) => {
lists = value;
// Get backend URL
function getBackendUrl(): string {
if (typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3007';
}
return 'http://localhost:3007';
}
async function fetchLists() {
if (!authStore.isAuthenticated) {
loading = false;
return;
}
const token = await authStore.getValidToken();
if (!token) {
loading = false;
return;
}
try {
const response = await fetch(`${getBackendUrl()}/api/lists`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
lists = data.lists || [];
}
} catch (error) {
console.error('Failed to fetch lists:', error);
} finally {
loading = false;
}
}
async function createList() {
if (!newListName.trim()) return;
const token = await authStore.getValidToken();
if (!token) return;
try {
const response = await fetch(`${getBackendUrl()}/api/lists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: newListName.trim(),
description: newListDescription.trim() || undefined,
}),
});
if (response.ok) {
const data = await response.json();
lists = [...lists, data.list];
showCreateModal = false;
newListName = '';
newListDescription = '';
}
} catch (error) {
console.error('Failed to create list:', error);
}
}
async function deleteList(listId: string) {
if (!confirm('Möchtest du diese Liste wirklich löschen?')) return;
const token = await authStore.getValidToken();
if (!token) return;
try {
const response = await fetch(`${getBackendUrl()}/api/lists/${listId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
lists = lists.filter((l) => l.id !== listId);
}
} catch (error) {
console.error('Failed to delete list:', error);
}
}
onMount(() => {
fetchLists();
});
// Filter lists by search term
let filteredLists = $derived(
lists.filter(
(list) =>
list.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
list.description?.toLowerCase().includes(searchTerm.toLowerCase())
)
);
function openCreateModal() {
showCreateModal = true;
newListName = '';
newListDescription = '';
}
function closeCreateModal() {
showCreateModal = false;
newListName = '';
newListDescription = '';
}
function handleCreateList() {
if (newListName.trim()) {
listsStore.createList(newListName.trim(), newListDescription.trim() || undefined);
toast.success('Liste erstellt!');
closeCreateModal();
}
}
function handleDeleteList(listId: string) {
if (confirm('Möchtest du diese Liste wirklich löschen?')) {
listsStore.deleteList(listId);
toast.info('Liste gelöscht');
}
}
function getQuoteCount(quoteIds: string[]): number {
return quoteIds.length;
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>Listen - Zitare</title>
<title>Zitare - {$_('lists.title')}</title>
</svelte:head>
<div class="lists-page">
<div class="header-container">
<PageHeader
title="Meine Listen"
description="{lists.length} {lists.length === 1 ? 'Liste' : 'Listen'}"
size="lg"
>
{#snippet actions()}
<button class="create-fab" onclick={openCreateModal} aria-label="Neue Liste erstellen">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
{/snippet}
</PageHeader>
{#if lists.length > 3}
<div class="search-container">
<input
type="text"
placeholder="Listen durchsuchen..."
bind:value={searchTerm}
class="search"
/>
</div>
{/if}
</div>
{#if lists.length === 0}
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</div>
<h3>Keine Listen</h3>
<p>Erstelle deine erste Liste, um Zitate zu organisieren</p>
<button class="cta-button" onclick={openCreateModal}>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-foreground">{$_('lists.title')}</h1>
{#if authStore.isAuthenticated}
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -134,45 +132,88 @@
d="M12 4v16m8-8H4"
/>
</svg>
Erste Liste erstellen
{$_('lists.create')}
</button>
{/if}
</div>
{#if !authStore.isAuthenticated}
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<svg
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<p class="text-foreground-secondary mb-4">Melde dich an, um Listen zu erstellen</p>
<button
onclick={() => goto('/login')}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
{$_('auth.login')}
</button>
</div>
{:else if filteredLists.length === 0}
<!-- No Search Results -->
<div class="empty-state">
<div class="empty-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
{:else if loading}
<div class="text-center py-12">
<div
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
{:else if lists.length === 0}
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<svg
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<h3>Keine Ergebnisse</h3>
<p>Versuche es mit anderen Suchbegriffen</p>
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p class="text-lg font-medium text-foreground mb-2">{$_('lists.empty')}</p>
<p class="text-foreground-secondary">{$_('lists.emptyDescription')}</p>
</div>
{:else}
<div class="lists-grid">
{#each filteredLists as list (list.id)}
<a href="/lists/{list.id}" class="list-card">
<div class="list-header">
<h3>{list.name}</h3>
<div class="grid gap-4">
{#each lists as list (list.id)}
<a
href="/lists/{list.id}"
class="block p-6 bg-surface-elevated rounded-2xl hover:shadow-lg transition-all group"
>
<div class="flex items-start justify-between">
<div>
<h3
class="text-lg font-semibold text-foreground group-hover:text-primary transition-colors"
>
{list.name}
</h3>
{#if list.description}
<p class="text-foreground-secondary mt-1">{list.description}</p>
{/if}
<p class="text-sm text-foreground-muted mt-2">
{list.quoteIds.length} Zitate
</p>
</div>
<button
class="delete-btn"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDeleteList(list.id);
deleteList(list.id);
}}
aria-label="Liste löschen"
class="p-2 text-foreground-muted hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -182,49 +223,23 @@
</svg>
</button>
</div>
{#if list.description}
<p class="list-description">{list.description}</p>
{/if}
<div class="list-meta">
<div class="meta-item">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
<span>{getQuoteCount(list.quoteIds)} Zitate</span>
</div>
<div class="meta-item">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{formatDate(list.updatedAt)}</span>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create List Modal -->
<!-- Create Modal -->
{#if showCreateModal}
<div class="modal-overlay" onclick={closeCreateModal}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Neue Liste erstellen</h3>
<button class="close-btn" onclick={closeCreateModal} aria-label="Schließen">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-surface-elevated rounded-2xl w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-6 border-b border-border">
<h3 class="text-xl font-semibold text-foreground">Neue Liste erstellen</h3>
<button
onclick={() => (showCreateModal = false)}
class="p-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -234,438 +249,43 @@
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="list-name">Name *</label>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-2">Name *</label>
<input
id="list-name"
type="text"
bind:value={newListName}
placeholder="z.B. Motivierende Zitate"
class="form-input"
maxlength="50"
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground focus:outline-none focus:border-primary"
/>
</div>
<div class="form-group">
<label for="list-description">Beschreibung (optional)</label>
<div>
<label class="block text-sm font-medium text-foreground mb-2"
>Beschreibung (optional)</label
>
<textarea
id="list-description"
bind:value={newListDescription}
placeholder="Was macht diese Liste besonders?"
class="form-textarea"
rows="3"
maxlength="200"
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground focus:outline-none focus:border-primary resize-none"
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick={closeCreateModal}> Abbrechen </button>
<button class="btn btn-primary" onclick={handleCreateList} disabled={!newListName.trim()}>
<div class="flex justify-end gap-3 p-6 border-t border-border">
<button
onclick={() => (showCreateModal = false)}
class="px-4 py-2 text-foreground-secondary hover:text-foreground transition-colors"
>
{$_('common.cancel')}
</button>
<button
onclick={createList}
disabled={!newListName.trim()}
class="px-6 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Erstellen
</button>
</div>
</div>
</div>
{/if}
<style>
.lists-page {
max-width: 1200px;
margin: 0 auto;
padding-bottom: var(--spacing-2xl);
}
.header-container {
max-width: 900px;
margin: 0 auto var(--spacing-xl);
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
h2 {
font-size: 2rem;
margin: 0 0 var(--spacing-xs) 0;
color: rgb(var(--color-text-primary));
}
.subtitle {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
margin: 0;
}
.create-fab {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 9999px;
background: rgb(var(--color-primary));
color: white;
border: none;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
flex-shrink: 0;
}
.create-fab:hover {
transform: scale(1.05);
box-shadow: var(--shadow-lg);
}
.create-fab:active {
transform: scale(0.95);
}
.search-container {
margin-top: var(--spacing-md);
}
.search {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
}
.search:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.lists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--spacing-lg);
max-width: 900px;
margin: 0 auto;
}
.list-card {
background: rgb(var(--color-surface));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-decoration: none;
color: inherit;
transition: all var(--transition-base);
display: block;
}
.list-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: rgb(var(--color-primary));
}
.list-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.list-card h3 {
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
margin: 0;
flex: 1;
}
.delete-btn {
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgb(var(--color-text-tertiary));
transition: all var(--transition-fast);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.delete-btn:hover {
color: rgb(var(--color-error));
background: rgba(var(--color-error), 0.1);
}
.list-description {
color: rgb(var(--color-text-secondary));
font-size: 0.9375rem;
margin: 0 0 var(--spacing-md) 0;
line-height: 1.5;
}
.list-meta {
display: flex;
gap: var(--spacing-lg);
padding-top: var(--spacing-sm);
border-top: 1px solid rgb(var(--color-border));
}
.meta-item {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.meta-item svg {
color: rgb(var(--color-text-tertiary));
}
/* Empty State */
.empty-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.empty-icon {
margin: 0 auto var(--spacing-lg);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-sm) 0;
}
.empty-state p {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0 0 var(--spacing-xl) 0;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-xl);
background: rgb(var(--color-primary));
color: white;
border: none;
border-radius: var(--radius-full);
font-weight: 500;
font-size: 1rem;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: var(--spacing-lg);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: rgb(var(--color-surface-elevated));
border-radius: var(--radius-xl);
max-width: 500px;
width: 100%;
box-shadow: var(--shadow-xl);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid rgb(var(--color-border));
}
.modal-header h3 {
font-size: 1.25rem;
margin: 0;
color: rgb(var(--color-text-primary));
}
.close-btn {
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgb(var(--color-text-secondary));
transition: all var(--transition-fast);
border-radius: var(--radius-sm);
}
.close-btn:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.modal-body {
padding: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
margin-bottom: var(--spacing-xs);
}
.form-input,
.form-textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-background));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
font-family: inherit;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border-top: 1px solid rgb(var(--color-border));
}
.btn {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.9375rem;
cursor: pointer;
transition: all var(--transition-base);
border: none;
}
.btn-secondary {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 1px solid rgb(var(--color-border));
}
.btn-secondary:hover {
background: rgb(var(--color-background));
}
.btn-primary {
background: rgb(var(--color-primary));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.lists-page {
padding-bottom: var(--spacing-xl);
}
.header-container {
max-width: 100%;
}
h2 {
font-size: 1.5rem;
}
.create-fab {
width: 2.5rem;
height: 2.5rem;
}
.lists-grid {
grid-template-columns: 1fr;
max-width: 100%;
}
.empty-state {
padding: var(--spacing-xl);
}
.modal {
margin: var(--spacing-md);
}
}
</style>

View file

@ -1,12 +1,18 @@
<script lang="ts">
import { page } from '$app/stores';
import { listsStore } from '$lib/stores/lists';
import type { QuoteList } from '$lib/stores/lists';
import { quotesDE, authorsDE } from '@zitare/shared';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { listsStore, type QuoteList } from '$lib/stores/lists.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { quotesStore } from '$lib/stores/quotes.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { QUOTES, type Quote } from '@zitare/content';
import QuoteCard from '$lib/components/QuoteCard.svelte';
import { toast } from '$lib/stores/toast';
const allQuotes = QUOTES;
let list = $state<QuoteList | null>(null);
let isLoading = $state(true);
let searchTerm = $state('');
let isSearchOpen = $state(false);
let showEditModal = $state(false);
@ -14,55 +20,47 @@
let editName = $state('');
let editDescription = $state('');
let selectedQuoteIds = $state<Set<string>>(new Set());
let favorites = $state<Set<string>>(new Set());
// Load favorites from localStorage
if (typeof window !== 'undefined') {
const savedFavorites = localStorage.getItem('favorites');
if (savedFavorites) {
favorites = new Set(JSON.parse(savedFavorites));
}
}
// Subscribe to lists and find current list
listsStore.subscribe((lists) => {
// Load list on mount
$effect(() => {
const listId = $page.params.id;
const foundList = lists.find((l) => l.id === listId);
if (foundList) {
list = foundList;
if (listId) {
loadList(listId);
}
});
async function loadList(listId: string) {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
isLoading = true;
list = await listsStore.getList(listId);
isLoading = false;
if (!list) {
toast.error('Liste nicht gefunden');
}
}
// Get quotes in this list
let listQuotes = $derived(
list
? quotesDE
.filter((quote) => list.quoteIds.includes(quote.id))
.map((quote) => ({
...quote,
author: authorsDE.find((a) => a.id === quote.authorId),
isFavorite: favorites.has(quote.id),
}))
: []
let listQuotes = $derived<Quote[]>(
list ? allQuotes.filter((quote: Quote) => list!.quoteIds.includes(quote.id)) : []
);
// Filter quotes by search
let filteredQuotes = $derived(
let filteredQuotes = $derived<Quote[]>(
listQuotes.filter(
(quote) =>
quote.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
quote.author?.name.toLowerCase().includes(searchTerm.toLowerCase())
(quote: Quote) =>
quotesStore.getText(quote).toLowerCase().includes(searchTerm.toLowerCase()) ||
quote.author.toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Get all available quotes (not in this list)
let availableQuotes = $derived(
quotesDE
.filter((quote) => !list?.quoteIds.includes(quote.id))
.map((quote) => ({
...quote,
author: authorsDE.find((a) => a.id === quote.authorId),
}))
// Get available quotes (not in this list)
let availableQuotes = $derived<Quote[]>(
allQuotes.filter((quote: Quote) => !list?.quoteIds.includes(quote.id))
);
function toggleSearch() {
@ -84,22 +82,31 @@
showEditModal = false;
}
function handleUpdateList() {
async function handleUpdateList() {
if (list && editName.trim()) {
listsStore.updateList(list.id, {
const updated = await listsStore.updateList(list.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
});
toast.success('Liste aktualisiert!');
closeEditModal();
if (updated) {
list = updated;
toast.success('Liste aktualisiert!');
closeEditModal();
} else {
toast.error('Fehler beim Aktualisieren');
}
}
}
function handleDeleteList() {
async function handleDeleteList() {
if (list && confirm('Möchtest du diese Liste wirklich löschen?')) {
listsStore.deleteList(list.id);
toast.info('Liste gelöscht');
window.location.href = '/lists';
const success = await listsStore.deleteList(list.id);
if (success) {
toast.info('Liste gelöscht');
goto('/lists');
} else {
toast.error('Fehler beim Löschen');
}
}
}
@ -122,47 +129,38 @@
selectedQuoteIds = new Set(selectedQuoteIds);
}
function handleAddQuotes() {
async function handleAddQuotes() {
if (list) {
const count = selectedQuoteIds.size;
selectedQuoteIds.forEach((quoteId) => {
listsStore.addQuoteToList(list.id, quoteId);
});
toast.success(`${count} ${count === 1 ? 'Zitat' : 'Zitate'} hinzugefügt!`);
let successCount = 0;
for (const quoteId of selectedQuoteIds) {
const success = await listsStore.addQuoteToList(list.id, quoteId);
if (success) successCount++;
}
if (successCount > 0) {
// Reload list to get updated quote IDs
list = await listsStore.getList(list.id);
toast.success(`${successCount} ${successCount === 1 ? 'Zitat' : 'Zitate'} hinzugefügt!`);
}
closeAddQuotesModal();
}
}
function handleRemoveQuote(quoteId: string) {
async function handleRemoveQuote(quoteId: string) {
if (list && confirm('Zitat aus dieser Liste entfernen?')) {
listsStore.removeQuoteFromList(list.id, quoteId);
toast.info('Zitat entfernt');
const success = await listsStore.removeQuoteFromList(list.id, quoteId);
if (success) {
// Reload list to get updated quote IDs
list = await listsStore.getList(list.id);
toast.info('Zitat entfernt');
} else {
toast.error('Fehler beim Entfernen');
}
}
}
function handleToggleFavorite(event: CustomEvent) {
const { quoteId } = event.detail;
if (favorites.has(quoteId)) {
favorites.delete(quoteId);
} else {
favorites.add(quoteId);
}
favorites = new Set(favorites);
if (typeof window !== 'undefined') {
localStorage.setItem('favorites', JSON.stringify([...favorites]));
}
}
function handleAuthorClick(event: CustomEvent) {
const { authorId } = event.detail;
if (authorId) {
window.location.href = `/authors/${authorId}`;
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('de-DE', {
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
@ -171,10 +169,15 @@
</script>
<svelte:head>
<title>{list?.name || 'Liste'} - Quotes Web App</title>
<title>{list?.name || 'Liste'} - Zitare</title>
</svelte:head>
{#if !list}
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>{$_('common.loading')}</p>
</div>
{:else if !list}
<div class="error-state">
<h2>Liste nicht gefunden</h2>
<p>Diese Liste existiert nicht oder wurde gelöscht.</p>
@ -324,11 +327,7 @@
<div class="quotes-grid">
{#each filteredQuotes as quote (quote.id)}
<div class="quote-wrapper">
<QuoteCard
{quote}
on:toggleFavorite={handleToggleFavorite}
on:authorClick={handleAuthorClick}
/>
<QuoteCard {quote} />
<button
class="remove-btn"
onclick={() => handleRemoveQuote(quote.id)}
@ -359,8 +358,8 @@
<!-- Edit List Modal -->
{#if showEditModal}
<div class="modal-overlay" onclick={closeEditModal}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div class="modal-header">
<h3>Liste bearbeiten</h3>
<button class="close-btn" onclick={closeEditModal} aria-label="Schließen">
@ -423,8 +422,13 @@
<!-- Add Quotes Modal -->
{#if showAddQuotesModal}
<div class="modal-overlay" onclick={closeAddQuotesModal}>
<div class="modal modal-large" onclick={(e) => e.stopPropagation()}>
<div class="modal-overlay" onclick={closeAddQuotesModal} role="presentation">
<div
class="modal modal-large"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div class="modal-header">
<h3>Zitate hinzufügen</h3>
<button class="close-btn" onclick={closeAddQuotesModal} aria-label="Schließen">
@ -448,8 +452,8 @@
onchange={() => toggleQuoteSelection(quote.id)}
/>
<div class="quote-preview">
<p class="quote-text">"{quote.text}"</p>
<p class="quote-author">{quote.author?.name || 'Unknown'}</p>
<p class="quote-text">"{quotesStore.getText(quote)}"</p>
<p class="quote-author">{quote.author}</p>
</div>
</label>
{/each}
@ -481,6 +485,30 @@
padding-bottom: var(--spacing-2xl);
}
.loading-state,
.error-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(var(--color-border));
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--spacing-md);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.header-container {
max-width: 700px;
margin: 0 auto var(--spacing-xl);
@ -633,9 +661,9 @@
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(var(--color-error), 0.1);
color: rgb(var(--color-error));
border: 1px solid rgba(var(--color-error), 0.3);
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
@ -651,8 +679,8 @@
}
.remove-btn:hover {
background: rgba(var(--color-error), 0.2);
border-color: rgba(var(--color-error), 0.5);
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
/* Empty State */
@ -694,6 +722,7 @@
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
text-decoration: none;
}
.cta-button:hover {
@ -701,24 +730,6 @@
box-shadow: var(--shadow-lg);
}
/* Error State */
.error-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.error-state h2 {
font-size: 1.5rem;
margin-bottom: var(--spacing-sm);
}
.error-state p {
color: rgb(var(--color-text-secondary));
margin-bottom: var(--spacing-xl);
}
/* Floating Results */
.floating-results {
position: fixed;
@ -735,18 +746,6 @@
font-size: 0.875rem;
font-weight: 500;
z-index: 20;
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translate(-50%, 10px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* Modal Styles */
@ -760,16 +759,6 @@
justify-content: center;
z-index: 50;
padding: var(--spacing-lg);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
@ -778,7 +767,6 @@
max-width: 500px;
width: 100%;
box-shadow: var(--shadow-xl);
animation: slideUp 0.3s ease;
max-height: 90vh;
display: flex;
flex-direction: column;
@ -788,17 +776,6 @@
max-width: 700px;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
@ -921,9 +898,9 @@
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: rgba(var(--color-error), 0.1);
color: rgb(var(--color-error));
border: 1px solid rgba(var(--color-error), 0.3);
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
@ -934,8 +911,8 @@
}
.danger-btn:hover {
background: rgba(var(--color-error), 0.2);
border-color: rgba(var(--color-error), 0.5);
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
.modal-footer {
@ -1007,15 +984,6 @@
font-size: 1.5rem;
}
.header-actions {
flex-direction: column;
}
.icon-btn {
width: 2.25rem;
height: 2.25rem;
}
.quotes-grid {
max-width: 100%;
}

View file

@ -1,507 +1,107 @@
<script lang="ts">
import { quotesDE, authorsDE } from '@zitare/shared';
import type { Quote, Author } from '@zitare/shared';
import { _ } from 'svelte-i18n';
import { searchQuotes, type Quote } from '@zitare/content';
import { quotesStore } from '$lib/stores/quotes.svelte';
import QuoteCard from '$lib/components/QuoteCard.svelte';
import AuthorCard from '$lib/components/AuthorCard.svelte';
let searchTerm = $state('');
let activeTab = $state<'all' | 'quotes' | 'authors'>('all');
let favorites = $state<Set<string>>(new Set());
let authorFavorites = $state<Set<string>>(new Set());
// Pagination
const ITEMS_PER_PAGE = 20;
let currentPage = $state(1);
// Load favorites from localStorage
if (typeof window !== 'undefined') {
const savedFavorites = localStorage.getItem('favorites');
if (savedFavorites) {
favorites = new Set(JSON.parse(savedFavorites));
}
const savedAuthorFavorites = localStorage.getItem('authorFavorites');
if (savedAuthorFavorites) {
authorFavorites = new Set(JSON.parse(savedAuthorFavorites));
}
}
// Search results
let filteredQuotes = $derived(
searchTerm.length >= 2
? quotesDE
.filter(
(q) =>
q.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
authorsDE
.find((a) => a.id === q.authorId)
?.name.toLowerCase()
.includes(searchTerm.toLowerCase())
)
.map((q) => ({
...q,
author: authorsDE.find((a) => a.id === q.authorId),
isFavorite: favorites.has(q.id),
}))
: []
let results = $derived<Quote[]>(
searchTerm.length >= 2 ? searchQuotes(searchTerm, quotesStore.language) : []
);
let filteredAuthors = $derived(
searchTerm.length >= 2
? authorsDE
.filter(
(a) =>
a.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.profession?.some((p) => p.toLowerCase().includes(searchTerm.toLowerCase()))
)
.map((a) => ({
...a,
quoteCount: quotesDE.filter((q) => q.authorId === a.id).length,
isFavorite: authorFavorites.has(a.id),
}))
: []
);
// Paginated results
let displayedQuotes = $derived(filteredQuotes.slice(0, currentPage * ITEMS_PER_PAGE));
let displayedAuthors = $derived(filteredAuthors.slice(0, currentPage * ITEMS_PER_PAGE));
// Total results
let totalResults = $derived(
activeTab === 'quotes'
? filteredQuotes.length
: activeTab === 'authors'
? filteredAuthors.length
: filteredQuotes.length + filteredAuthors.length
);
let hasMoreQuotes = $derived(displayedQuotes.length < filteredQuotes.length);
let hasMoreAuthors = $derived(displayedAuthors.length < filteredAuthors.length);
// Reset page when search or tab changes
$effect(() => {
searchTerm;
activeTab;
currentPage = 1;
});
function loadMore() {
currentPage++;
}
function handleToggleFavorite(event: CustomEvent) {
const { quoteId } = event.detail;
if (favorites.has(quoteId)) {
favorites.delete(quoteId);
} else {
favorites.add(quoteId);
}
favorites = new Set(favorites);
if (typeof window !== 'undefined') {
localStorage.setItem('favorites', JSON.stringify([...favorites]));
}
}
function handleAuthorToggleFavorite(event: CustomEvent) {
const { authorId } = event.detail;
if (authorFavorites.has(authorId)) {
authorFavorites.delete(authorId);
} else {
authorFavorites.add(authorId);
}
authorFavorites = new Set(authorFavorites);
if (typeof window !== 'undefined') {
localStorage.setItem('authorFavorites', JSON.stringify([...authorFavorites]));
}
}
function handleAuthorClick(event: CustomEvent) {
const { author, authorId } = event.detail;
const id = author?.id || authorId;
if (id) {
window.location.href = `/authors/${id}`;
}
}
</script>
<svelte:head>
<title>Suche - Zitare</title>
<meta name="description" content="Durchsuche Zitate und Autoren" />
<title>Zitare - {$_('search.title')}</title>
</svelte:head>
<div class="search-page">
<div class="search-header">
<h2>Suche</h2>
<div class="search-input-wrapper">
<div class="max-w-3xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-6">{$_('search.title')}</h1>
<!-- Search Input -->
<div class="relative mb-8">
<svg
class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder={$_('search.placeholder')}
bind:value={searchTerm}
class="w-full pl-12 pr-4 py-4 rounded-xl bg-surface-elevated border border-border text-foreground text-lg focus:outline-none focus:border-primary transition-colors"
/>
{#if searchTerm}
<button
onclick={() => (searchTerm = '')}
class="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-muted hover:text-foreground transition-colors"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<!-- Results -->
{#if searchTerm.length >= 2}
{#if results.length === 0}
<div class="text-center py-12">
<svg
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8" stroke-width="1.5"></circle>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="m21 21-4.35-4.35"
></path>
</svg>
<p class="text-foreground-secondary">{$_('search.noResults')}</p>
</div>
{:else}
<p class="text-foreground-secondary mb-6">
{$_('search.results', { values: { count: results.length } })}
</p>
<div class="space-y-6">
{#each results as quote (quote.id)}
<QuoteCard {quote} showCategory showSource />
{/each}
</div>
{/if}
{:else if searchTerm.length > 0}
<p class="text-center text-foreground-muted py-8">Bitte gib mindestens 2 Zeichen ein</p>
{:else}
<div class="text-center py-12">
<svg
class="search-icon"
width="20"
height="20"
class="w-16 h-16 mx-auto text-foreground-muted mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
<circle cx="11" cy="11" r="8" stroke-width="1.5"></circle>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m21 21-4.35-4.35"
></path>
</svg>
<input
type="text"
placeholder="Zitate oder Autoren suchen..."
bind:value={searchTerm}
class="search-input"
autofocus
/>
{#if searchTerm}
<button class="clear-btn" onclick={() => (searchTerm = '')} aria-label="Clear search">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
{#if searchTerm.length >= 2}
<!-- Tabs -->
<div class="tabs">
<button class="tab" class:active={activeTab === 'all'} onclick={() => (activeTab = 'all')}>
Alle ({filteredQuotes.length + filteredAuthors.length})
</button>
<button
class="tab"
class:active={activeTab === 'quotes'}
onclick={() => (activeTab = 'quotes')}
>
Zitate ({filteredQuotes.length})
</button>
<button
class="tab"
class:active={activeTab === 'authors'}
onclick={() => (activeTab = 'authors')}
>
Autoren ({filteredAuthors.length})
</button>
</div>
{#if totalResults === 0}
<div class="empty-state">
<div class="empty-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<h3>Keine Ergebnisse</h3>
<p>Versuche es mit anderen Suchbegriffen</p>
</div>
{:else}
<!-- Results -->
<div class="results">
<!-- Quotes Section -->
{#if (activeTab === 'all' || activeTab === 'quotes') && displayedQuotes.length > 0}
{#if activeTab === 'all'}
<h3 class="section-title">Zitate ({filteredQuotes.length})</h3>
{/if}
<div class="quotes-list">
{#each displayedQuotes as quote (quote.id)}
<QuoteCard
{quote}
on:toggleFavorite={handleToggleFavorite}
on:authorClick={handleAuthorClick}
/>
{/each}
</div>
{#if activeTab === 'quotes' && hasMoreQuotes}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore}>
Mehr laden ({filteredQuotes.length - displayedQuotes.length} weitere)
</button>
</div>
{/if}
{/if}
<!-- Authors Section -->
{#if (activeTab === 'all' || activeTab === 'authors') && displayedAuthors.length > 0}
{#if activeTab === 'all'}
<h3 class="section-title">Autoren ({filteredAuthors.length})</h3>
{/if}
<div class="authors-list">
{#each displayedAuthors as author (author.id)}
<AuthorCard
{author}
isFavorite={author.isFavorite}
on:click={handleAuthorClick}
on:toggleFavorite={handleAuthorToggleFavorite}
/>
{/each}
</div>
{#if activeTab === 'authors' && hasMoreAuthors}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore}>
Mehr laden ({filteredAuthors.length - displayedAuthors.length} weitere)
</button>
</div>
{/if}
{/if}
</div>
{/if}
{:else if searchTerm.length > 0}
<div class="hint">
<p>Bitte gib mindestens 2 Zeichen ein</p>
</div>
{:else}
<div class="hint">
<div class="hint-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<p>Suche nach Zitaten, Autoren oder Themen</p>
<p class="text-foreground-secondary">Suche nach Zitaten, Autoren oder Themen</p>
</div>
{/if}
</div>
<style>
.search-page {
max-width: 700px;
margin: 0 auto;
padding-bottom: var(--spacing-2xl);
}
.search-header {
margin-bottom: var(--spacing-xl);
}
h2 {
font-size: 2rem;
margin: 0 0 var(--spacing-lg) 0;
color: rgb(var(--color-text-primary));
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 1rem;
color: rgb(var(--color-text-tertiary));
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--spacing-md) var(--spacing-md) var(--spacing-md) 3rem;
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
font-size: 1rem;
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.clear-btn {
position: absolute;
right: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: rgb(var(--color-text-tertiary));
cursor: pointer;
border-radius: var(--radius-full);
transition: all var(--transition-fast);
}
.clear-btn:hover {
background: rgb(var(--color-border));
color: rgb(var(--color-text-primary));
}
/* Tabs */
.tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab {
padding: var(--spacing-sm) var(--spacing-lg);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-full);
background: rgb(var(--color-surface));
color: rgb(var(--color-text-secondary));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
}
.tab:hover {
border-color: rgb(var(--color-primary));
color: rgb(var(--color-text-primary));
}
.tab.active {
background: rgb(var(--color-primary));
border-color: rgb(var(--color-primary));
color: white;
}
/* Results */
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.section-title {
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
margin: 0;
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid rgb(var(--color-border));
}
.quotes-list,
.authors-list {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
}
.empty-icon {
margin: 0 auto var(--spacing-lg);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-sm) 0;
}
.empty-state p {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0;
}
/* Hint */
.hint {
text-align: center;
padding: var(--spacing-2xl);
color: rgb(var(--color-text-secondary));
}
.hint-icon {
margin-bottom: var(--spacing-md);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.hint p {
margin: 0;
font-size: 1rem;
}
/* Load More */
.load-more-container {
text-align: center;
margin-top: var(--spacing-lg);
}
.load-more-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-2xl);
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-full);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-base);
}
.load-more-btn:hover {
background: rgb(var(--color-primary));
color: white;
border-color: rgb(var(--color-primary));
}
@media (max-width: 768px) {
.search-page {
padding-bottom: var(--spacing-xl);
}
h2 {
font-size: 1.5rem;
}
.search-input {
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-sm) 2.5rem;
}
.tabs {
margin-bottom: var(--spacing-lg);
}
.tab {
padding: var(--spacing-xs) var(--spacing-md);
font-size: 0.8125rem;
}
}
</style>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { quotesStore } from '$lib/stores/quotes.svelte';
import type { SupportedLanguage } from '@zitare/content';
// Language options for quotes
const languageOptions: { value: SupportedLanguage; label: string }[] = [
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'it', label: 'Italiano' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'original', label: 'Original' },
];
function handleLanguageChange(event: Event) {
const select = event.target as HTMLSelectElement;
quotesStore.setLanguage(select.value as SupportedLanguage);
}
</script>
<svelte:head>
<title>Zitare - {$_('nav.settings')}</title>
</svelte:head>
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-8">{$_('nav.settings')}</h1>
<div class="space-y-6">
<!-- Quote Language -->
<div class="bg-surface-elevated rounded-2xl p-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Zitat-Sprache</h2>
<p class="text-foreground-secondary text-sm mb-4">
Wähle die Sprache, in der die Zitate angezeigt werden sollen.
</p>
<select
value={quotesStore.language}
onchange={handleLanguageChange}
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground"
>
{#each languageOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- About -->
<div class="bg-surface-elevated rounded-2xl p-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Über Zitare</h2>
<p class="text-foreground-secondary text-sm">
Zitare bietet dir täglich inspirierende Zitate von den größten Denkern der Geschichte.
Speichere deine Favoriten und erstelle eigene Listen.
</p>
<p class="text-foreground-muted text-sm mt-4">
{quotesStore.totalCount} Zitate · 10 Kategorien · 6 Sprachen
</p>
</div>
</div>
</div>

View file

@ -0,0 +1,13 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="min-h-screen flex items-center justify-center p-4 bg-background">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-foreground">Zitare</h1>
<p class="text-foreground-secondary mt-2">Inspirierende Zitate jeden Tag</p>
</div>
{@render children()}
</div>
</div>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { isLoading as isLocaleLoading } from 'svelte-i18n';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { quotesStore } from '$lib/stores/quotes.svelte';
import { waitLocale } from '$lib/i18n';
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
let { children } = $props();
let loading = $state(true);
onMount(() => {
// Setup global error handling
const cleanupErrorHandler = setupGlobalErrorHandler();
// Initialize async operations
const init = async () => {
// Wait for locale to be loaded
await waitLocale();
// Initialize theme
theme.initialize();
// Initialize quotes store
quotesStore.initialize();
// Initialize auth
await authStore.initialize();
loading = false;
};
init();
return cleanupErrorHandler;
});
</script>
<ToastContainer />
{#if $isLocaleLoading || loading}
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="text-center">
<div
class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-foreground-secondary">Zitare</p>
</div>
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

View file

@ -0,0 +1,16 @@
{
"name": "Zitare",
"short_name": "Zitare",
"description": "Inspirierende Zitate jeden Tag",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#8b5cf6",
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192",
"type": "image/png"
}
]
}

View file

@ -0,0 +1,14 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
}),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { MANACORE_SHARED_PACKAGES } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5107,
strictPort: true,
},
ssr: {
noExternal: [...MANACORE_SHARED_PACKAGES, '@zitare/content'],
},
optimizeDeps: {
exclude: [...MANACORE_SHARED_PACKAGES, '@zitare/content'],
},
});

View file

@ -93,7 +93,10 @@ export class SessionService {
// 1. Try Redis first
if (this.useRedis()) {
const token = await this.redisProvider!.getToken(matrixUserId);
if (token) return token;
if (token) {
this.logger.debug(`Found token in Redis for ${matrixUserId}`);
return token;
}
}
// 2. Try in-memory cache
@ -102,14 +105,19 @@ export class SessionService {
if (session.expiresAt < new Date()) {
this.sessions.delete(matrixUserId);
} else {
this.logger.debug(`Found token in memory for ${matrixUserId}`);
return session.token;
}
}
// 3. Try Matrix-SSO-Link (automatic login)
this.logger.debug(
`No cached token for ${matrixUserId}, trying SSO-Link (enabled: ${this.enableMatrixSsoLink}, hasServiceKey: ${!!this.serviceKey})`
);
if (this.enableMatrixSsoLink) {
const token = await this.fetchMatrixLinkedToken(matrixUserId);
if (token) {
this.logger.log(`Matrix-SSO-Link: auto-login successful for ${matrixUserId}`);
// Cache the token
await this.storeSession(matrixUserId, {
token,