mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
🔧 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:
parent
7e1e8e9378
commit
ef9bd5656d
34 changed files with 2340 additions and 2149 deletions
52
apps/zitare/apps/web/package.json
Normal file
52
apps/zitare/apps/web/package.json
Normal 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"
|
||||
}
|
||||
63
apps/zitare/apps/web/src/app.css
Normal file
63
apps/zitare/apps/web/src/app.css
Normal 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%);
|
||||
}
|
||||
21
apps/zitare/apps/web/src/app.html
Normal file
21
apps/zitare/apps/web/src/app.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
155
apps/zitare/apps/web/src/lib/components/QuoteCard.svelte
Normal file
155
apps/zitare/apps/web/src/lib/components/QuoteCard.svelte
Normal 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>
|
||||
49
apps/zitare/apps/web/src/lib/i18n/index.ts
Normal file
49
apps/zitare/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
73
apps/zitare/apps/web/src/lib/i18n/locales/de.json
Normal file
73
apps/zitare/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
73
apps/zitare/apps/web/src/lib/i18n/locales/en.json
Normal file
73
apps/zitare/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
146
apps/zitare/apps/web/src/lib/stores/favorites.svelte.ts
Normal file
146
apps/zitare/apps/web/src/lib/stores/favorites.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
203
apps/zitare/apps/web/src/lib/stores/lists.svelte.ts
Normal file
203
apps/zitare/apps/web/src/lib/stores/lists.svelte.ts
Normal 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,
|
||||
};
|
||||
117
apps/zitare/apps/web/src/lib/stores/quotes.svelte.ts
Normal file
117
apps/zitare/apps/web/src/lib/stores/quotes.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
96
apps/zitare/apps/web/src/lib/stores/theme.svelte.ts
Normal file
96
apps/zitare/apps/web/src/lib/stores/theme.svelte.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
33
apps/zitare/apps/web/src/lib/stores/toast.svelte.ts
Normal file
33
apps/zitare/apps/web/src/lib/stores/toast.svelte.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
217
apps/zitare/apps/web/src/routes/(app)/+layout.svelte
Normal file
217
apps/zitare/apps/web/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
65
apps/zitare/apps/web/src/routes/(app)/+page.svelte
Normal file
65
apps/zitare/apps/web/src/routes/(app)/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
82
apps/zitare/apps/web/src/routes/(app)/favorites/+page.svelte
Normal file
82
apps/zitare/apps/web/src/routes/(app)/favorites/+page.svelte
Normal 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>
|
||||
24
apps/zitare/apps/web/src/routes/(app)/feedback/+page.svelte
Normal file
24
apps/zitare/apps/web/src/routes/(app)/feedback/+page.svelte
Normal 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')}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
59
apps/zitare/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
59
apps/zitare/apps/web/src/routes/(app)/settings/+page.svelte
Normal 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>
|
||||
13
apps/zitare/apps/web/src/routes/(auth)/+layout.svelte
Normal file
13
apps/zitare/apps/web/src/routes/(auth)/+layout.svelte
Normal 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>
|
||||
57
apps/zitare/apps/web/src/routes/+layout.svelte
Normal file
57
apps/zitare/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
0
apps/zitare/apps/web/static/favicon.png
Normal file
0
apps/zitare/apps/web/static/favicon.png
Normal file
16
apps/zitare/apps/web/static/manifest.json
Normal file
16
apps/zitare/apps/web/static/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
apps/zitare/apps/web/svelte.config.js
Normal file
14
apps/zitare/apps/web/svelte.config.js
Normal 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;
|
||||
14
apps/zitare/apps/web/tsconfig.json
Normal file
14
apps/zitare/apps/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
apps/zitare/apps/web/vite.config.ts
Normal file
17
apps/zitare/apps/web/vite.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue