feat(zitare): add vertical scroll-snap quote feed with infinite scroll

- Replace single quote view with TikTok-style vertical scrolling feed
- Add infinite scroll to load more quotes as user scrolls
- Remove discover page (consolidated into homepage)
- Fix theme store to use appId instead of storagePrefix
- Fix Sidebar theme toggle to use toggleMode() and isDark
- Add zitare and picture branding to shared-branding config
- Add missing manacore, mana, moodlit URLs to APP_URLS

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-01 14:57:05 +01:00
parent 4e4db4612c
commit 6cfab65b96
8 changed files with 165 additions and 40 deletions

View file

@ -11,6 +11,8 @@ pnpm dev:picture:app
pnpm dev:manacore:app
pnpm dev:zitare:app
Übersicht aller wichtigen Befehle zum Starten, Stoppen und Verwalten der Apps.

View file

@ -109,9 +109,9 @@
<div class="divider"></div>
<!-- Theme Toggle -->
<button onclick={() => theme.toggle()} class="nav-item">
<button onclick={() => theme.toggleMode()} class="nav-item">
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{#if $theme === 'dark'}
{#if theme.isDark}
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -127,7 +127,7 @@
/>
{/if}
</svg>
<span>{$theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
<span>{theme.isDark ? 'Light Mode' : 'Dark Mode'}</span>
</button>
</nav>
@ -209,13 +209,13 @@
<!-- Theme Toggle Mobile -->
<button
onclick={() => {
theme.toggle();
theme.toggleMode();
showUserMenu = false;
}}
class="mobile-nav-item"
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{#if $theme === 'dark'}
{#if theme.isDark}
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -231,7 +231,7 @@
/>
{/if}
</svg>
{$theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
{theme.isDark ? 'Light Mode' : 'Dark Mode'}
</button>
</nav>
</div>

View file

@ -2,6 +2,6 @@ import { createThemeStore } from '@manacore/shared-theme';
// Create theme store with Zitare's primary color (amber)
export const theme = createThemeStore({
appId: 'zitare',
defaultVariant: 'lume',
storagePrefix: 'zitare',
});

View file

@ -1,26 +1,37 @@
<script lang="ts">
import { onMount } from 'svelte';
import { quotesDE, authorsDE, getRandomQuote } from '@zitare/shared';
import QuoteCard from '$lib/components/QuoteCard.svelte';
// Get a random quote for the homepage
const randomQuote = getRandomQuote(quotesDE);
const quote = randomQuote
? {
...randomQuote,
author: authorsDE.find((a) => a.id === randomQuote.authorId),
isFavorite: false,
}
: null;
// Load multiple quotes for scrolling
let quotes = $state<any[]>([]);
let favorites = $state<Set<string>>(new Set());
let containerRef = $state<HTMLElement | null>(null);
function loadMoreQuotes(count = 5) {
const newQuotes = [];
for (let i = 0; i < count; i++) {
const randomQuote = getRandomQuote(quotesDE);
if (randomQuote) {
newQuotes.push({
...randomQuote,
author: authorsDE.find((a) => a.id === randomQuote.authorId),
isFavorite: favorites.has(randomQuote.id),
});
}
}
quotes = [...quotes, ...newQuotes];
}
// Load favorites from localStorage
if (typeof window !== 'undefined') {
onMount(() => {
const savedFavorites = localStorage.getItem('favorites');
if (savedFavorites) {
favorites = new Set(JSON.parse(savedFavorites));
}
}
// Initial load
loadMoreQuotes(10);
});
function handleToggleFavorite(event: CustomEvent) {
const { quoteId } = event.detail;
@ -31,10 +42,11 @@
}
favorites = new Set(favorites);
// Update quote's favorite status
quotes = quotes.map((q) => (q.id === quoteId ? { ...q, isFavorite: favorites.has(quoteId) } : q));
// Save to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('favorites', JSON.stringify([...favorites]));
}
localStorage.setItem('favorites', JSON.stringify([...favorites]));
}
function handleAuthorClick(event: CustomEvent) {
@ -43,6 +55,15 @@
window.location.href = `/authors/${authorId}`;
}
}
// Infinite scroll - load more when near bottom
function handleScroll(event: Event) {
const target = event.target as HTMLElement;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (scrollBottom < 500) {
loadMoreQuotes(5);
}
}
</script>
<svelte:head>
@ -50,27 +71,105 @@
<meta name="description" content="Entdecke inspirierende Zitate von großen Denkern" />
</svelte:head>
<div class="home">
{#if quote}
<QuoteCard
{quote}
variant="daily"
on:toggleFavorite={handleToggleFavorite}
on:authorClick={handleAuthorClick}
/>
<div class="scroll-container" bind:this={containerRef} onscroll={handleScroll}>
{#each quotes as quote, index (quote.id + '-' + index)}
<div class="quote-slide">
<QuoteCard
{quote}
variant="daily"
on:toggleFavorite={handleToggleFavorite}
on:authorClick={handleAuthorClick}
/>
</div>
{/each}
{#if quotes.length > 0}
<div class="scroll-hint">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<span>Scrollen für mehr</span>
</div>
{/if}
</div>
<style>
.home {
.scroll-container {
height: 100%;
flex: 1;
overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
padding-top: 100px; /* Space for floating nav */
}
.quote-slide {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.quote-slide:first-child {
padding-top: 0;
min-height: calc(100vh - 100px);
}
.quote-slide :global(.quote-card) {
width: 100%;
max-width: 600px;
margin: 0 auto;
}
.scroll-hint {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-text-tertiary);
font-size: 0.8125rem;
pointer-events: none;
opacity: 0.7;
}
.scroll-hint svg {
animation: bounce-arrow 2s infinite;
}
@keyframes bounce-arrow {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(6px);
}
60% {
transform: translateY(3px);
}
}
@media (max-width: 768px) {
.home {
max-width: 100%;
.scroll-container {
padding-top: 80px;
}
.quote-slide {
padding: 1rem;
}
.quote-slide:first-child {
min-height: calc(100vh - 80px);
}
.scroll-hint {
bottom: 1.5rem;
}
}
</style>

View file

@ -1,5 +0,0 @@
<script lang="ts">
import { DiscoverAppsPage } from '@zitare/web-ui';
</script>
<DiscoverAppsPage currentAppName="quotes" />

View file

@ -105,6 +105,32 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
zitare: {
id: 'zitare',
name: 'Zitare',
tagline: 'Daily Inspiration',
primaryColor: '#f59e0b',
secondaryColor: '#fbbf24',
// Quote/chat bubble icon
logoPath:
'M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
picture: {
id: 'picture',
name: 'Picture',
tagline: 'AI Image Generation',
primaryColor: '#3b82f6',
secondaryColor: '#60a5fa',
// Image/picture icon
logoPath:
'M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
};
/**

View file

@ -257,6 +257,9 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
zitare: { dev: 'http://localhost:5180', prod: 'https://zitare.manacore.app' },
wisekeep: { dev: 'http://localhost:5181', prod: 'https://wisekeep.manacore.app' },
nutriphi: { dev: 'http://localhost:5182', prod: 'https://nutriphi.manacore.app' },
manacore: { dev: 'http://localhost:5173', prod: 'https://manacore.app' },
mana: { dev: 'http://localhost:5173', prod: 'https://manacore.app' },
moodlit: { dev: 'http://localhost:5183', prod: 'https://moodlit.manacore.app' },
};
/**

View file

@ -1,7 +1,7 @@
/**
* App identifiers for branding
*/
export type AppId = 'memoro' | 'manacore' | 'manadeck' | 'maerchenzauber' | 'uload' | 'chat' | 'presi' | 'nutriphi';
export type AppId = 'memoro' | 'manacore' | 'manadeck' | 'maerchenzauber' | 'uload' | 'chat' | 'presi' | 'nutriphi' | 'zitare' | 'picture';
/**
* App branding configuration