mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 12:06:42 +02:00
feat(quote): integrate Quote app into monorepo
- Add complete Quote app with mobile (Expo), web (SvelteKit), landing (Astro), and backend (NestJS) - Create NestJS backend with Drizzle ORM for PostgreSQL - Add API endpoints for favorites and user lists - Add database schema for favorites and user_lists tables - Update root package.json with quote dev scripts - Add Quote environment variables to generate-env.mjs - Add missing toast.ts store for web app - Configure hybrid content strategy (static + API) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3a8d6bcf94
commit
ea3285dcbb
285 changed files with 645599 additions and 8 deletions
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts" generics="T extends ContentItem">
|
||||
import type { ContentItem, AppConfig } from '@quote/shared';
|
||||
import ContentCard from './ContentCard.svelte';
|
||||
import { toast } from '../stores/toast';
|
||||
|
||||
interface Props {
|
||||
config: AppConfig;
|
||||
allContent: T[];
|
||||
allAuthors: any[];
|
||||
favoriteStorageKey: string;
|
||||
contentTypeSingular: string; // "Zitat", "Sprichwort", "Gedicht"
|
||||
679
apps/quote/packages/web-ui/src/components/AppSidebar.svelte
Normal file
679
apps/quote/packages/web-ui/src/components/AppSidebar.svelte
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
<script lang="ts">
|
||||
import type { AppConfig } from '@quote/shared';
|
||||
import { isSidebarCollapsed } from '../stores/sidebar';
|
||||
import { theme } from '../stores/theme';
|
||||
|
||||
interface Props {
|
||||
config: AppConfig;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
let { config, currentPath }: Props = $props();
|
||||
let showUserMenu = $state(false);
|
||||
|
||||
function isActive(path: string) {
|
||||
return currentPath === path;
|
||||
}
|
||||
|
||||
// Build nav items from config
|
||||
// For web apps, we use a simple structure: Home, Browse, Favorites (if enabled), Discover Apps
|
||||
const navItems = $derived([
|
||||
{ path: '/', label: 'Home', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ path: '/browse', label: `Alle ${config.contentLabel.plural}`, icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253' },
|
||||
...(config.features.favorites ? [{ path: '/favorites', label: 'Favoriten', icon: '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' }] : []),
|
||||
{ path: '/discover', label: 'Apps entdecken', icon: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z' }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<style>
|
||||
@media (max-width: 1023px) {
|
||||
.mobile-header {
|
||||
display: block !important;
|
||||
}
|
||||
.mobile-bottom-nav {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.desktop-sidebar {
|
||||
display: flex !important;
|
||||
}
|
||||
.sidebar-toggle {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Sidebar Toggle Button (when collapsed) -->
|
||||
<button
|
||||
onclick={() => isSidebarCollapsed.set(false)}
|
||||
class="sidebar-toggle"
|
||||
class:collapsed={$isSidebarCollapsed}
|
||||
aria-label="Sidebar öffnen"
|
||||
style="background: rgb({config.colors.primary});"
|
||||
>
|
||||
<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="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside
|
||||
class="desktop-sidebar"
|
||||
class:collapsed={$isSidebarCollapsed}
|
||||
>
|
||||
<!-- Logo & Collapse Button -->
|
||||
<div class="sidebar-header">
|
||||
<a href="/" class="sidebar-logo">
|
||||
{config.metadata.displayName}
|
||||
</a>
|
||||
<button
|
||||
onclick={() => isSidebarCollapsed.set(true)}
|
||||
class="collapse-btn"
|
||||
aria-label="Sidebar schließen"
|
||||
>
|
||||
<svg width="20" height="20" 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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.path)}
|
||||
<a
|
||||
href={item.path}
|
||||
class="nav-item"
|
||||
class:active
|
||||
style={active ? `background: rgb(${config.colors.primary}); box-shadow: 0 2px 8px rgba(${config.colors.primary}, 0.3);` : ''}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
|
||||
</svg>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
onclick={() => theme.toggle()}
|
||||
class="nav-item"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{#if $theme === 'dark'}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{$theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- User Section -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-section">
|
||||
<button
|
||||
onclick={() => (showUserMenu = !showUserMenu)}
|
||||
class="user-button"
|
||||
>
|
||||
<div class="user-avatar" style="background: rgb({config.colors.primary});">
|
||||
U
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<p class="user-name">User</p>
|
||||
<p class="user-role">Account</p>
|
||||
</div>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
class="chevron"
|
||||
class:rotated={showUserMenu}
|
||||
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 showUserMenu}
|
||||
<div class="user-menu">
|
||||
<button
|
||||
onclick={() => (showUserMenu = false)}
|
||||
class="user-menu-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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Profil & Einstellungen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Header -->
|
||||
<header class="mobile-header">
|
||||
<div class="mobile-header-content">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="mobile-logo">
|
||||
{config.metadata.displayName}
|
||||
</a>
|
||||
|
||||
<!-- User Avatar -->
|
||||
<button
|
||||
onclick={() => (showUserMenu = !showUserMenu)}
|
||||
class="mobile-avatar"
|
||||
style="background: rgb({config.colors.primary});"
|
||||
>
|
||||
U
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile User Menu -->
|
||||
{#if showUserMenu}
|
||||
<div class="mobile-menu">
|
||||
<nav class="mobile-menu-nav">
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.path)}
|
||||
<a
|
||||
href={item.path}
|
||||
onclick={() => (showUserMenu = false)}
|
||||
class="mobile-nav-item"
|
||||
class:active
|
||||
style={active ? `background: rgba(${config.colors.primary}, 0.15); color: rgb(${config.colors.primary});` : ''}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
|
||||
</svg>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Theme Toggle Mobile -->
|
||||
<button
|
||||
onclick={() => { theme.toggle(); showUserMenu = false; }}
|
||||
class="mobile-nav-item"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{#if $theme === 'dark'}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
{/if}
|
||||
</svg>
|
||||
{$theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Mobile Bottom Navigation -->
|
||||
<nav class="mobile-bottom-nav">
|
||||
<div class="bottom-nav-grid">
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.path)}
|
||||
<a
|
||||
href={item.path}
|
||||
class="bottom-nav-item"
|
||||
class:active
|
||||
style={active ? `background: rgba(${config.colors.primary}, 0.15); color: rgb(${config.colors.primary});` : ''}
|
||||
>
|
||||
<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={item.icon} />
|
||||
</svg>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
/* Sidebar Toggle Button */
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 1rem;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
height: 3.5rem;
|
||||
width: 3.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
color: white;
|
||||
box-shadow: var(--shadow-xl);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.sidebar-toggle.collapsed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-toggle:not(.collapsed) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Desktop Sidebar */
|
||||
.desktop-sidebar {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
z-index: 40;
|
||||
display: none;
|
||||
height: calc(100vh - 2rem);
|
||||
width: 16rem;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(var(--color-surface), 0.8);
|
||||
border: 1px solid rgba(var(--color-border), 0.5);
|
||||
box-shadow: var(--shadow-xl);
|
||||
backdrop-filter: blur(20px);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.desktop-sidebar.collapsed {
|
||||
transform: translateX(calc(-100% - 2rem));
|
||||
}
|
||||
|
||||
/* Sidebar Header */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
height: 4rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid rgba(var(--color-border), 0.5);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--color-text-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
transition: color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
color: rgb(var(--color-text-primary));
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.nav-item:hover svg {
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.nav-item:active {
|
||||
transform: translateX(2px) scale(0.98);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item.active svg {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item.active:hover {
|
||||
transform: translateX(0) scale(1.02);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 1rem 0;
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid rgba(var(--color-border), 0.5);
|
||||
}
|
||||
|
||||
.user-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.user-button:hover {
|
||||
background: rgba(var(--color-primary), 0.05);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevron.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: rgba(var(--color-surface), 0.95);
|
||||
border: 1px solid rgba(var(--color-border), 0.5);
|
||||
box-shadow: var(--shadow-lg);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
}
|
||||
|
||||
/* Mobile Header */
|
||||
.mobile-header {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: rgb(var(--color-surface));
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.mobile-header-content {
|
||||
display: flex;
|
||||
height: 4rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.mobile-logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--color-text-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mobile-avatar {
|
||||
display: flex;
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.mobile-menu-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 1px solid rgba(var(--color-border), 0.3);
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-nav-item svg {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
transition: color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-item:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.mobile-nav-item:hover svg {
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.mobile-nav-item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.mobile-nav-item.active svg {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Mobile Bottom Nav */
|
||||
.mobile-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
background: rgb(var(--color-surface));
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.bottom-nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bottom-nav-item:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.bottom-nav-item:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active svg {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bottom-nav-item span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
503
apps/quote/packages/web-ui/src/components/BrowsePage.svelte
Normal file
503
apps/quote/packages/web-ui/src/components/BrowsePage.svelte
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
<script lang="ts" generics="T extends ContentItem">
|
||||
import type { ContentItem, AppConfig } from '@quote/shared';
|
||||
import ContentCard from './ContentCard.svelte';
|
||||
import { toast } from '../stores/toast';
|
||||
|
||||
interface Props {
|
||||
config: AppConfig;
|
||||
content: T[];
|
||||
allAuthors?: any[];
|
||||
favoriteStorageKey: string;
|
||||
showAuthor?: boolean;
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
let {
|
||||
config,
|
||||
content,
|
||||
allAuthors = [],
|
||||
favoriteStorageKey,
|
||||
showAuthor = true,
|
||||
pageTitle
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let selectedCategory = $state('all');
|
||||
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(favoriteStorageKey);
|
||||
if (savedFavorites) {
|
||||
favorites = new Set(JSON.parse(savedFavorites));
|
||||
}
|
||||
}
|
||||
|
||||
// Get content with author info
|
||||
const contentWithAuthors = content.map(item => ({
|
||||
...item,
|
||||
author: allAuthors.find(a => a.id === item.authorId)
|
||||
}));
|
||||
|
||||
// Get unique categories
|
||||
const categories = ['all', ...new Set(content.flatMap(item => item.categories || []).filter(Boolean))];
|
||||
|
||||
// Filter content (all matching items)
|
||||
let allFilteredContent = $derived(
|
||||
contentWithAuthors
|
||||
.map(item => ({
|
||||
...item,
|
||||
isFavorite: favorites.has(item.id)
|
||||
}))
|
||||
.filter(item => {
|
||||
const matchesSearch = item.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.author?.name?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' ||
|
||||
item.categories?.includes(selectedCategory);
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
);
|
||||
|
||||
// Paginated content (only show what should be visible)
|
||||
let filteredContent = $derived(
|
||||
allFilteredContent.slice(0, currentPage * ITEMS_PER_PAGE)
|
||||
);
|
||||
|
||||
// Check if there are more items to load
|
||||
let hasMore = $derived(filteredContent.length < allFilteredContent.length);
|
||||
|
||||
function toggleSearch() {
|
||||
isSearchOpen = !isSearchOpen;
|
||||
if (!isSearchOpen) {
|
||||
searchTerm = '';
|
||||
selectedCategory = 'all';
|
||||
currentPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
isLoadingMore = true;
|
||||
setTimeout(() => {
|
||||
currentPage++;
|
||||
isLoadingMore = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Reset page when search/filter changes
|
||||
$effect(() => {
|
||||
searchTerm;
|
||||
selectedCategory;
|
||||
currentPage = 1;
|
||||
});
|
||||
|
||||
function handleToggleFavorite(event: CustomEvent) {
|
||||
const { contentId } = event.detail;
|
||||
const wasAdded = !favorites.has(contentId);
|
||||
|
||||
if (favorites.has(contentId)) {
|
||||
favorites.delete(contentId);
|
||||
} else {
|
||||
favorites.add(contentId);
|
||||
}
|
||||
favorites = new Set(favorites);
|
||||
|
||||
// Save to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(favoriteStorageKey, JSON.stringify([...favorites]));
|
||||
}
|
||||
|
||||
// Show toast
|
||||
if (wasAdded) {
|
||||
toast.success('Zu Favoriten hinzugefügt');
|
||||
} else {
|
||||
toast.info('Von Favoriten entfernt');
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthorClick(event: CustomEvent) {
|
||||
const { authorId } = event.detail;
|
||||
if (authorId) {
|
||||
window.location.href = `/authors/${authorId}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle} - {config.metadata.displayName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="browse-page">
|
||||
<div class="header-container">
|
||||
<div class="header-row">
|
||||
<h2>{pageTitle}</h2>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{#if isSearchOpen}
|
||||
<div class="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Durchsuchen..."
|
||||
bind:value={searchTerm}
|
||||
class="search"
|
||||
/>
|
||||
|
||||
<select bind:value={selectedCategory} class="category-filter">
|
||||
{#each categories as category}
|
||||
<option value={category}>
|
||||
{category === 'all' ? 'Alle Kategorien' : category}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if allFilteredContent.length === 0 && (searchTerm || selectedCategory !== 'all')}
|
||||
<!-- 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 Ergebnisse gefunden</h3>
|
||||
<p>Versuche es mit anderen Suchbegriffen oder Kategorien</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="content-grid">
|
||||
{#each filteredContent as item (item.id)}
|
||||
<ContentCard
|
||||
content={item}
|
||||
on:toggleFavorite={handleToggleFavorite}
|
||||
on:authorClick={handleAuthorClick}
|
||||
/>
|
||||
{/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 ({allFilteredContent.length - filteredContent.length} weitere)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isSearchOpen}
|
||||
<div class="floating-results">
|
||||
{allFilteredContent.length} von {content.length} {config.contentLabel.plural}
|
||||
{#if filteredContent.length < allFilteredContent.length}
|
||||
• {filteredContent.length} angezeigt
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.browse-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 {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
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 {
|
||||
flex: 1;
|
||||
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));
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
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);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.category-filter:focus {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.content-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) {
|
||||
.browse-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 {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
gap: var(--spacing-lg);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.floating-results {
|
||||
bottom: 5rem;
|
||||
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,73 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
categories: string[];
|
||||
selectedCategory: string | null;
|
||||
onSelect: (category: string | null) => void;
|
||||
primaryColor?: string;
|
||||
}
|
||||
|
||||
let { categories, selectedCategory = $bindable(null), onSelect, primaryColor }: Props = $props();
|
||||
|
||||
function handleSelect(category: string | null) {
|
||||
selectedCategory = category;
|
||||
onSelect(category);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="category-filters">
|
||||
<button
|
||||
class="category-btn"
|
||||
class:active={!selectedCategory}
|
||||
onclick={() => handleSelect(null)}
|
||||
style={!selectedCategory && primaryColor ? `background: rgb(${primaryColor}); border-color: rgb(${primaryColor}); color: white;` : ''}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{#each categories as category}
|
||||
<button
|
||||
class="category-btn"
|
||||
class:active={selectedCategory === category}
|
||||
onclick={() => handleSelect(category)}
|
||||
style={selectedCategory === category && primaryColor ? `background: rgb(${primaryColor}); border-color: rgb(${primaryColor}); color: white;` : ''}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.category-filters {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border: 2px solid rgb(var(--color-border));
|
||||
background: rgb(var(--color-background));
|
||||
color: rgb(var(--color-text-secondary));
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: rgb(var(--color-primary));
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: rgb(var(--color-primary));
|
||||
border-color: rgb(var(--color-primary));
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.category-filters {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
398
apps/quote/packages/web-ui/src/components/ContentCard.svelte
Normal file
398
apps/quote/packages/web-ui/src/components/ContentCard.svelte
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
<script lang="ts" generics="T extends ContentItem">
|
||||
import type { ContentItem } from '@quote/shared';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { toast } from '../stores/toast';
|
||||
|
||||
interface Props {
|
||||
content: T & { author?: any; isFavorite?: boolean };
|
||||
variant?: 'simple' | 'daily';
|
||||
category?: string;
|
||||
showAuthor?: boolean;
|
||||
showSource?: boolean;
|
||||
gradientStyle?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
content,
|
||||
variant = 'simple',
|
||||
category,
|
||||
showAuthor = true,
|
||||
showSource = true,
|
||||
gradientStyle
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Get gradient colors based on category
|
||||
function getCategoryGradient(cat?: string): string {
|
||||
const gradients: Record<string, string> = {
|
||||
'life': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'wisdom': 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
'success': 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
'motivation': 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
'love': 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
||||
'happiness': 'linear-gradient(135deg, #30cfd0 0%, #330867 100%)',
|
||||
'philosophy': 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)',
|
||||
'courage': 'linear-gradient(135deg, #ff9a56 0%, #ff6a88 100%)',
|
||||
'creativity': 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
||||
'peace': 'linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%)',
|
||||
'knowledge': 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
||||
};
|
||||
|
||||
if (cat && gradients[cat.toLowerCase()]) {
|
||||
return gradients[cat.toLowerCase()];
|
||||
}
|
||||
|
||||
// Default gradient
|
||||
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
const authorName = content.author?.name || content.authorId || 'Unknown';
|
||||
const text = `"${content.text}" — ${authorName}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
dispatch('copy', { content });
|
||||
showCopyFeedback();
|
||||
toast.success('Kopiert!');
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
const authorName = content.author?.name || content.authorId || 'Unknown';
|
||||
const text = `"${content.text}" — ${authorName}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'Content',
|
||||
text: text,
|
||||
}).catch((error) => {
|
||||
if (error.name !== 'AbortError') {
|
||||
handleCopy();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
handleCopy();
|
||||
}
|
||||
|
||||
dispatch('share', { content });
|
||||
}
|
||||
|
||||
function handleFavorite() {
|
||||
dispatch('toggleFavorite', { contentId: content.id });
|
||||
}
|
||||
|
||||
function handleAuthorClick() {
|
||||
dispatch('authorClick', { authorId: content.author?.id || content.authorId });
|
||||
}
|
||||
|
||||
let showCopySuccess = $state(false);
|
||||
|
||||
function showCopyFeedback() {
|
||||
showCopySuccess = true;
|
||||
setTimeout(() => {
|
||||
showCopySuccess = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const finalGradient = gradientStyle || getCategoryGradient(category || content.categories?.[0]);
|
||||
const isDaily = variant === 'daily';
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="content-card"
|
||||
class:daily={isDaily}
|
||||
style="background: {finalGradient}"
|
||||
>
|
||||
<div class="card-inner">
|
||||
<!-- Content Text -->
|
||||
<blockquote class="content-text">
|
||||
<p>"{content.text}"</p>
|
||||
</blockquote>
|
||||
|
||||
<!-- Source Info (for quotes) -->
|
||||
{#if !isDaily && showSource && 'source' in content && content.source}
|
||||
<p class="source-info">
|
||||
From: {content.source}
|
||||
{#if 'year' in content && content.year}
|
||||
({content.year})
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Origin Info (for proverbs) -->
|
||||
{#if !isDaily && 'origin' in content && content.origin}
|
||||
<p class="source-info">
|
||||
{content.origin}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Meaning (for proverbs) -->
|
||||
{#if 'meaning' in content && content.meaning}
|
||||
<div class="meaning-box">
|
||||
<strong>Bedeutung:</strong>
|
||||
<p>{content.meaning}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Author Section -->
|
||||
{#if showAuthor}
|
||||
<div class="author-section">
|
||||
<button
|
||||
class="author-info"
|
||||
onclick={handleAuthorClick}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<p class="author-name">
|
||||
{content.author?.name || content.authorId || 'Unknown'}
|
||||
</p>
|
||||
{#if content.author?.profession && content.author.profession.length > 0}
|
||||
<p class="author-profession">
|
||||
{content.author.profession[0]}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<!-- Copy Button -->
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={handleCopy}
|
||||
title="Copy"
|
||||
aria-label="Copy 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={handleShare}
|
||||
title="Share"
|
||||
aria-label="Share"
|
||||
>
|
||||
<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={content.isFavorite}
|
||||
onclick={handleFavorite}
|
||||
title={content.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
aria-label={content.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{#if content.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>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.content-card {
|
||||
position: relative;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 1px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.content-card.daily {
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.content-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px 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(var(--radius-xl) - 1px);
|
||||
padding: var(--spacing-xl);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.daily .card-inner {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 31px;
|
||||
padding: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.content-text {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content-text p {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 1.375rem;
|
||||
line-height: 2rem;
|
||||
color: white;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.3px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.daily .content-text p {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2.125rem;
|
||||
}
|
||||
|
||||
.source-info {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.meaning-box {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.meaning-box strong {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.meaning-box p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.author-section {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: var(--spacing-md);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.daily .author-section {
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.author-info {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.author-info:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
|
||||
.author-profession {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.content-text p {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
<script lang="ts">
|
||||
interface AppInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
url: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentAppName: string;
|
||||
pageTitle?: string;
|
||||
}
|
||||
|
||||
let { currentAppName, pageTitle = 'Apps entdecken' }: Props = $props();
|
||||
|
||||
const allApps: AppInfo[] = [
|
||||
{
|
||||
name: 'quotes',
|
||||
displayName: 'Zitate',
|
||||
description: 'Inspirierende Zitate von großen Denkern und Philosophen',
|
||||
icon: '💭',
|
||||
color: '#667eea',
|
||||
url: 'http://localhost:5173',
|
||||
features: ['1000+ Zitate', 'Berühmte Autoren', 'Kategorien & Tags']
|
||||
},
|
||||
{
|
||||
name: 'proverbs',
|
||||
displayName: 'Sprichwörter',
|
||||
description: 'Zeitlose Weisheiten und Redewendungen aus aller Welt',
|
||||
icon: '📜',
|
||||
color: '#f59e0b',
|
||||
url: 'http://localhost:5171',
|
||||
features: ['Deutsche Sprichwörter', 'Volksweisheiten', 'Redensarten']
|
||||
},
|
||||
{
|
||||
name: 'poems',
|
||||
displayName: 'Gedichte',
|
||||
description: 'Klassische und moderne Gedichte der deutschen Literatur',
|
||||
icon: '✍️',
|
||||
color: '#ec4899',
|
||||
url: 'http://localhost:5172',
|
||||
features: ['Klassische Gedichte', 'Verschiedene Epochen', 'Berühmte Dichter']
|
||||
},
|
||||
{
|
||||
name: 'fables',
|
||||
displayName: 'Fabeln',
|
||||
description: 'Klassische Fabeln von Äsop, La Fontaine und Lessing',
|
||||
icon: '🦊',
|
||||
color: '#8b5cf6',
|
||||
url: 'http://localhost:5174',
|
||||
features: ['Äsop Fabeln', 'Moralische Lehren', 'Tiergeschichten']
|
||||
}
|
||||
];
|
||||
|
||||
const otherApps = $derived(allApps.filter(app => app.name !== currentAppName));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="discover-page">
|
||||
<div class="header">
|
||||
<h1>{pageTitle}</h1>
|
||||
<p class="subtitle">Entdecke weitere Apps aus unserer Sammlung</p>
|
||||
</div>
|
||||
|
||||
<div class="apps-grid">
|
||||
{#each otherApps as app (app.name)}
|
||||
<a href={app.url} target="_blank" rel="noopener noreferrer" class="app-card">
|
||||
<div class="app-icon" style="background: linear-gradient(135deg, {app.color} 0%, {app.color}dd 100%)">
|
||||
<span class="icon-emoji">{app.icon}</span>
|
||||
</div>
|
||||
|
||||
<div class="app-content">
|
||||
<h2 class="app-title">{app.displayName}</h2>
|
||||
<p class="app-description">{app.description}</p>
|
||||
|
||||
<div class="app-features">
|
||||
{#each app.features as feature}
|
||||
<span class="feature-badge">{feature}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-cta">
|
||||
<span class="cta-text">App öffnen</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="m12 5 7 7-7 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
<p>Alle Apps teilen sich das gleiche moderne Design und nutzen dieselbe Technologie für ein einheitliches Erlebnis.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.discover-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--color-text-primary));
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.app-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgb(var(--color-surface));
|
||||
border: 2px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, rgb(var(--color-primary)) 0%, rgb(var(--color-primary-dark)) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: rgb(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.app-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.icon-emoji {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--color-text-primary));
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 1rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
line-height: 1.6;
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.app-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.feature-badge {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: rgb(var(--color-primary) / 0.1);
|
||||
color: rgb(var(--color-primary));
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: rgb(var(--color-primary));
|
||||
font-weight: 600;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.app-card:hover .app-cta {
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.app-card:hover .app-cta svg {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.app-cta svg {
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background: rgb(var(--color-primary) / 0.05);
|
||||
border: 1px solid rgb(var(--color-primary) / 0.2);
|
||||
border-radius: var(--radius-lg);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.info-box svg {
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--color-primary));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.apps-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.app-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.icon-emoji {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.info-box svg {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
159
apps/quote/packages/web-ui/src/components/ErrorBoundary.svelte
Normal file
159
apps/quote/packages/web-ui/src/components/ErrorBoundary.svelte
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: any;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
let hasError = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
||||
onMount(() => {
|
||||
// Global error handler
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
console.error('Error caught:', event.error);
|
||||
hasError = true;
|
||||
errorMessage = event.error?.message || 'Ein unerwarteter Fehler ist aufgetreten';
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
hasError = true;
|
||||
errorMessage = event.reason?.message || 'Ein unerwarteter Fehler ist aufgetreten';
|
||||
};
|
||||
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
});
|
||||
|
||||
function handleReset() {
|
||||
hasError = false;
|
||||
errorMessage = '';
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasError}
|
||||
<div class="error-boundary">
|
||||
<div class="error-container">
|
||||
<div class="error-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="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Etwas ist schiefgelaufen</h2>
|
||||
<p class="error-message">{errorMessage}</p>
|
||||
<div class="error-actions">
|
||||
<button class="btn btn-primary" onclick={handleReset}>
|
||||
Seite neu laden
|
||||
</button>
|
||||
<a href="/" class="btn btn-secondary">
|
||||
Zur Startseite
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.error-boundary {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
background: rgb(var(--color-background));
|
||||
}
|
||||
|
||||
.error-container {
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin: 0 auto var(--spacing-lg);
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
margin: 0 0 var(--spacing-xl) 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-xl);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgb(var(--color-primary));
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-boundary {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
560
apps/quote/packages/web-ui/src/components/FavoritesPage.svelte
Normal file
560
apps/quote/packages/web-ui/src/components/FavoritesPage.svelte
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
<script lang="ts" generics="T extends ContentItem">
|
||||
import type { ContentItem, AppConfig } from '@quote/shared';
|
||||
import ContentCard from './ContentCard.svelte';
|
||||
import { toast } from '../stores/toast';
|
||||
|
||||
interface Props {
|
||||
config: AppConfig;
|
||||
allContent: T[];
|
||||
allAuthors: any[];
|
||||
favoriteStorageKey: string;
|
||||
contentTypeSingular: string; // "Zitat", "Sprichwort", "Gedicht"
|
||||
contentTypePlural: string; // "Zitate", "Sprichwörter", "Gedichte"
|
||||
browseUrl: string; // URL to browse all content (e.g., "/quotes", "/proverbs")
|
||||
}
|
||||
|
||||
let {
|
||||
config,
|
||||
allContent,
|
||||
allAuthors,
|
||||
favoriteStorageKey,
|
||||
contentTypeSingular,
|
||||
contentTypePlural,
|
||||
browseUrl
|
||||
}: Props = $props();
|
||||
|
||||
let favorites = $state<Set<string>>(new Set());
|
||||
let searchTerm = $state('');
|
||||
let selectedCategory = $state('all');
|
||||
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(favoriteStorageKey);
|
||||
if (savedFavorites) {
|
||||
favorites = new Set(JSON.parse(savedFavorites));
|
||||
}
|
||||
}
|
||||
|
||||
// Get favorite content with author info
|
||||
let favoriteContent = $derived(
|
||||
allContent
|
||||
.filter(item => favorites.has(item.id))
|
||||
.map(item => ({
|
||||
...item,
|
||||
author: allAuthors.find(a => a.id === item.authorId),
|
||||
isFavorite: true
|
||||
}))
|
||||
);
|
||||
|
||||
// Get unique categories from favorites
|
||||
const categories = $derived([
|
||||
'all',
|
||||
...new Set(favoriteContent.flatMap(item => item.categories || []).filter(Boolean))
|
||||
]);
|
||||
|
||||
// Filter content (all matching items)
|
||||
let allFilteredContent = $derived(
|
||||
favoriteContent.filter(item => {
|
||||
const matchesSearch = item.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.author?.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' ||
|
||||
item.categories?.includes(selectedCategory);
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
);
|
||||
|
||||
// Paginated content (only show what should be visible)
|
||||
let filteredContent = $derived(
|
||||
allFilteredContent.slice(0, currentPage * ITEMS_PER_PAGE)
|
||||
);
|
||||
|
||||
// Check if there are more items to load
|
||||
let hasMore = $derived(filteredContent.length < allFilteredContent.length);
|
||||
|
||||
function toggleSearch() {
|
||||
isSearchOpen = !isSearchOpen;
|
||||
if (!isSearchOpen) {
|
||||
searchTerm = '';
|
||||
selectedCategory = 'all';
|
||||
currentPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
isLoadingMore = true;
|
||||
setTimeout(() => {
|
||||
currentPage++;
|
||||
isLoadingMore = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Reset page when search/filter changes
|
||||
$effect(() => {
|
||||
searchTerm;
|
||||
selectedCategory;
|
||||
currentPage = 1;
|
||||
});
|
||||
|
||||
function handleToggleFavorite(event: CustomEvent) {
|
||||
const { contentId } = event.detail;
|
||||
const wasRemoved = favorites.has(contentId);
|
||||
|
||||
if (favorites.has(contentId)) {
|
||||
favorites.delete(contentId);
|
||||
} else {
|
||||
favorites.add(contentId);
|
||||
}
|
||||
favorites = new Set(favorites);
|
||||
|
||||
// Save to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(favoriteStorageKey, JSON.stringify([...favorites]));
|
||||
}
|
||||
|
||||
// Show toast
|
||||
if (wasRemoved) {
|
||||
toast.info('Von Favoriten entfernt');
|
||||
} else {
|
||||
toast.success('Zu Favoriten hinzugefügt');
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthorClick(event: CustomEvent) {
|
||||
const { authorId } = event.detail;
|
||||
if (authorId) {
|
||||
window.location.href = `/authors/${authorId}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Favoriten - {config.metadata.displayName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="favorites-page">
|
||||
<div class="header-container">
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h2>Favoriten</h2>
|
||||
<p class="subtitle">{favoriteContent.length} gespeicherte {favoriteContent.length === 1 ? contentTypeSingular : contentTypePlural}</p>
|
||||
</div>
|
||||
|
||||
{#if favoriteContent.length > 0}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isSearchOpen}
|
||||
<div class="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Favoriten durchsuchen..."
|
||||
bind:value={searchTerm}
|
||||
class="search"
|
||||
/>
|
||||
|
||||
{#if categories.length > 1}
|
||||
<select bind:value={selectedCategory} class="category-filter">
|
||||
{#each categories as category}
|
||||
<option value={category}>
|
||||
{category === 'all' ? 'Alle Kategorien' : category}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if favoriteContent.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">
|
||||
<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>
|
||||
</div>
|
||||
<h3>Keine Favoriten</h3>
|
||||
<p>Markiere {contentTypePlural} als Favoriten, um sie hier zu sehen</p>
|
||||
<a href={browseUrl} class="cta-button">
|
||||
{contentTypePlural} entdecken
|
||||
</a>
|
||||
</div>
|
||||
{:else if filteredContent.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" 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}
|
||||
<div class="content-grid">
|
||||
{#each filteredContent as content (content.id)}
|
||||
<ContentCard
|
||||
{content}
|
||||
on:toggleFavorite={handleToggleFavorite}
|
||||
on:authorClick={handleAuthorClick}
|
||||
/>
|
||||
{/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 ({allFilteredContent.length - filteredContent.length} weitere)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isSearchOpen && allFilteredContent.length > 0}
|
||||
<div class="floating-results">
|
||||
{allFilteredContent.length} von {favoriteContent.length} Favoriten
|
||||
{#if filteredContent.length < allFilteredContent.length}
|
||||
" {filteredContent.length} angezeigt
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.favorites-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: 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;
|
||||
}
|
||||
|
||||
.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);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-fab:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.search-fab:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
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 {
|
||||
flex: 1;
|
||||
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));
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
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);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.category-filter:focus {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.favorites-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;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.search-fab {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
gap: var(--spacing-lg);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.floating-results {
|
||||
bottom: 5rem;
|
||||
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>
|
||||
53
apps/quote/packages/web-ui/src/components/PageHeader.svelte
Normal file
53
apps/quote/packages/web-ui/src/components/PageHeader.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
primaryColor?: string;
|
||||
primaryDarkColor?: string;
|
||||
}
|
||||
|
||||
let { title, subtitle, primaryColor, primaryDarkColor }: Props = $props();
|
||||
|
||||
const gradientStyle = $derived(() => {
|
||||
if (primaryColor && primaryDarkColor) {
|
||||
return `background: linear-gradient(135deg, rgb(${primaryColor}), rgb(${primaryDarkColor})); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;`;
|
||||
}
|
||||
return `background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-primary-dark))); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="title" style={gradientStyle()}>{title}</h1>
|
||||
{#if subtitle}
|
||||
<p class="subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
66
apps/quote/packages/web-ui/src/components/SearchBox.svelte
Normal file
66
apps/quote/packages/web-ui/src/components/SearchBox.svelte
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onInput?: (value: string) => void;
|
||||
}
|
||||
|
||||
let { value = $bindable(''), placeholder = 'Suchen...', onInput }: Props = $props();
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
value = target.value;
|
||||
if (onInput) {
|
||||
onInput(target.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search-box">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
oninput={handleInput}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.search-box svg {
|
||||
position: absolute;
|
||||
left: var(--spacing-md);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: rgb(var(--color-text-tertiary));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-md) var(--spacing-md) calc(var(--spacing-md) * 3);
|
||||
border: 2px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 1rem;
|
||||
background-color: rgb(var(--color-background));
|
||||
color: rgb(var(--color-text-primary));
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: rgb(var(--color-text-tertiary));
|
||||
}
|
||||
</style>
|
||||
697
apps/quote/packages/web-ui/src/components/Sidebar.svelte
Normal file
697
apps/quote/packages/web-ui/src/components/Sidebar.svelte
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { isSidebarCollapsed } from '../stores/sidebar';
|
||||
import { theme } from '../stores/theme';
|
||||
|
||||
let showUserMenu = $state(false);
|
||||
|
||||
function isActive(path: string) {
|
||||
return $page.url.pathname === path;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
path: '/',
|
||||
label: 'Home',
|
||||
icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'
|
||||
},
|
||||
{
|
||||
path: '/quotes',
|
||||
label: 'Zitate',
|
||||
icon: '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'
|
||||
},
|
||||
{
|
||||
path: '/authors',
|
||||
label: 'Autoren',
|
||||
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: 'Einstellungen',
|
||||
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<style>
|
||||
@media (max-width: 1023px) {
|
||||
.mobile-header {
|
||||
display: block !important;
|
||||
}
|
||||
.mobile-bottom-nav {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.desktop-sidebar {
|
||||
display: flex !important;
|
||||
}
|
||||
.sidebar-toggle {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Sidebar Toggle Button (when collapsed) -->
|
||||
<button
|
||||
onclick={() => isSidebarCollapsed.set(false)}
|
||||
class="sidebar-toggle"
|
||||
class:collapsed={$isSidebarCollapsed}
|
||||
aria-label="Sidebar öffnen"
|
||||
>
|
||||
<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="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside
|
||||
class="desktop-sidebar"
|
||||
class:collapsed={$isSidebarCollapsed}
|
||||
>
|
||||
<!-- Logo & Collapse Button -->
|
||||
<div class="sidebar-header">
|
||||
<a href="/" class="sidebar-logo">
|
||||
📖 Zitare
|
||||
</a>
|
||||
<button
|
||||
onclick={() => isSidebarCollapsed.set(true)}
|
||||
class="collapse-btn"
|
||||
aria-label="Sidebar schließen"
|
||||
>
|
||||
<svg width="20" height="20" 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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.path)}
|
||||
<a
|
||||
href={item.path}
|
||||
class="nav-item"
|
||||
class:active
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
|
||||
</svg>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
onclick={() => theme.toggle()}
|
||||
class="nav-item"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{#if $theme === 'dark'}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{$theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- User Section -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-section">
|
||||
<button
|
||||
onclick={() => (showUserMenu = !showUserMenu)}
|
||||
class="user-button"
|
||||
>
|
||||
<div class="user-avatar">
|
||||
U
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<p class="user-name">User</p>
|
||||
<p class="user-role">Account</p>
|
||||
</div>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
class="chevron"
|
||||
class:rotated={showUserMenu}
|
||||
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 showUserMenu}
|
||||
<div class="user-menu">
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={() => (showUserMenu = false)}
|
||||
class="user-menu-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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Profil & Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Header -->
|
||||
<header class="mobile-header">
|
||||
<div class="mobile-header-content">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="mobile-logo">
|
||||
📖 Zitare
|
||||
</a>
|
||||
|
||||
<!-- User Avatar -->
|
||||
<button
|
||||
onclick={() => (showUserMenu = !showUserMenu)}
|
||||
class="mobile-avatar"
|
||||
>
|
||||
U
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile User Menu -->
|
||||
{#if showUserMenu}
|
||||
<div class="mobile-menu">
|
||||
<nav class="mobile-menu-nav">
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.path)}
|
||||
<a
|
||||
href={item.path}
|
||||
onclick={() => (showUserMenu = false)}
|
||||
class="mobile-nav-item"
|
||||
class:active
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
|
||||
</svg>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Theme Toggle Mobile -->
|
||||
<button
|
||||
onclick={() => { theme.toggle(); showUserMenu = false; }}
|
||||
class="mobile-nav-item"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{#if $theme === 'dark'}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
{/if}
|
||||
</svg>
|
||||
{$theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Mobile Bottom Navigation -->
|
||||
<nav class="mobile-bottom-nav">
|
||||
<div class="bottom-nav-grid">
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.path)}
|
||||
<a
|
||||
href={item.path}
|
||||
class="bottom-nav-item"
|
||||
class:active
|
||||
>
|
||||
<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={item.icon} />
|
||||
</svg>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
/* Sidebar Toggle Button */
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 1rem;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
height: 3.5rem;
|
||||
width: 3.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-primary));
|
||||
color: white;
|
||||
box-shadow: var(--shadow-xl);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.sidebar-toggle:not(.collapsed) {
|
||||
transform: translateX(calc(-100% - 2rem));
|
||||
}
|
||||
|
||||
/* Desktop Sidebar */
|
||||
.desktop-sidebar {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
z-index: 40;
|
||||
display: none;
|
||||
height: calc(100vh - 2rem);
|
||||
width: 16rem;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(var(--color-surface), 0.8);
|
||||
border: 1px solid rgba(var(--color-border), 0.5);
|
||||
box-shadow: var(--shadow-xl);
|
||||
backdrop-filter: blur(20px);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.desktop-sidebar.collapsed {
|
||||
transform: translateX(calc(-100% - 2rem));
|
||||
}
|
||||
|
||||
/* Sidebar Header */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
height: 4rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid rgba(var(--color-border), 0.5);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--color-text-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
transition: color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
color: rgb(var(--color-text-primary));
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.nav-item:hover svg {
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.nav-item:active {
|
||||
transform: translateX(2px) scale(0.98);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgb(var(--color-primary));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(var(--color-primary), 0.3);
|
||||
}
|
||||
|
||||
.nav-item.active svg {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item.active:hover {
|
||||
background: rgb(var(--color-primary));
|
||||
transform: translateX(0) scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(var(--color-primary), 0.4);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 1rem 0;
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid rgba(var(--color-border), 0.5);
|
||||
}
|
||||
|
||||
.user-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.user-button:hover {
|
||||
background: rgba(var(--color-primary), 0.05);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-primary));
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevron.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: rgba(var(--color-surface), 0.95);
|
||||
border: 1px solid rgba(var(--color-border), 0.5);
|
||||
box-shadow: var(--shadow-lg);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
}
|
||||
|
||||
/* Mobile Header */
|
||||
.mobile-header {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: rgb(var(--color-surface));
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.mobile-header-content {
|
||||
display: flex;
|
||||
height: 4rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.mobile-logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--color-text-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mobile-avatar {
|
||||
display: flex;
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-primary));
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.mobile-menu-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 1px solid rgba(var(--color-border), 0.3);
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-nav-item svg {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
transition: color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-item:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.mobile-nav-item:hover svg {
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.mobile-nav-item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.mobile-nav-item.active {
|
||||
background: rgba(var(--color-primary), 0.15);
|
||||
color: rgb(var(--color-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-nav-item.active svg {
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Mobile Bottom Nav */
|
||||
.mobile-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
background: rgb(var(--color-surface));
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.bottom-nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bottom-nav-item:hover {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.bottom-nav-item:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active {
|
||||
background: rgba(var(--color-primary), 0.15);
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.bottom-nav-item.active svg {
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.bottom-nav-item span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
191
apps/quote/packages/web-ui/src/components/ToastContainer.svelte
Normal file
191
apps/quote/packages/web-ui/src/components/ToastContainer.svelte
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts">
|
||||
import { toast, type Toast } from '../stores/toast';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
toast.subscribe(value => {
|
||||
toasts = value;
|
||||
});
|
||||
|
||||
function handleClose(id: string) {
|
||||
toast.remove(id);
|
||||
}
|
||||
|
||||
function getIcon(type: Toast['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>`;
|
||||
case 'error':
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>`;
|
||||
case 'warning':
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>`;
|
||||
case 'info':
|
||||
default:
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toast-container">
|
||||
{#each toasts as toastItem (toastItem.id)}
|
||||
<div
|
||||
class="toast toast-{toastItem.type}"
|
||||
transition:fly={{ y: 20, duration: 300 }}
|
||||
role="alert"
|
||||
>
|
||||
<div class="toast-icon">
|
||||
{@html getIcon(toastItem.type)}
|
||||
</div>
|
||||
<p class="toast-message">{toastItem.message}</p>
|
||||
<button
|
||||
class="toast-close"
|
||||
onclick={() => handleClose(toastItem.id)}
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid rgb(var(--color-success));
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: rgb(var(--color-success));
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
border-left: 4px solid rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left: 4px solid rgb(var(--color-info));
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: rgb(var(--color-info));
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
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);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
background: rgba(var(--color-border), 0.5);
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.toast-container {
|
||||
bottom: 6rem; /* Above mobile bottom nav */
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for progress bar (optional enhancement) */
|
||||
@keyframes shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
18
apps/quote/packages/web-ui/src/index.ts
Normal file
18
apps/quote/packages/web-ui/src/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Components
|
||||
export { default as ContentCard } from './components/ContentCard.svelte';
|
||||
export { default as AppSidebar } from './components/AppSidebar.svelte';
|
||||
export { default as BrowsePage } from './components/BrowsePage.svelte';
|
||||
export { default as FavoritesPage } from './components/FavoritesPage.svelte';
|
||||
export { default as DiscoverAppsPage } from './components/DiscoverAppsPage.svelte';
|
||||
export { default as PageHeader } from './components/PageHeader.svelte';
|
||||
export { default as SearchBox } from './components/SearchBox.svelte';
|
||||
export { default as CategoryFilters } from './components/CategoryFilters.svelte';
|
||||
export { default as ToastContainer } from './components/ToastContainer.svelte';
|
||||
|
||||
// Stores
|
||||
export { isSidebarCollapsed } from './stores/sidebar';
|
||||
export { theme } from './stores/theme';
|
||||
export { toast, type Toast, type ToastType } from './stores/toast';
|
||||
|
||||
// Note: ToastContainer and ErrorBoundary are available
|
||||
// but should be imported directly when needed to avoid unnecessary dependencies
|
||||
122
apps/quote/packages/web-ui/src/stores/lists.ts
Normal file
122
apps/quote/packages/web-ui/src/stores/lists.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface QuoteList {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
quoteIds: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const LISTS_KEY = 'quote-lists';
|
||||
|
||||
function createListsStore() {
|
||||
// Load initial data from localStorage
|
||||
const initialLists: QuoteList[] = browser
|
||||
? JSON.parse(localStorage.getItem(LISTS_KEY) || '[]')
|
||||
: [];
|
||||
|
||||
const { subscribe, set, update } = writable<QuoteList[]>(initialLists);
|
||||
|
||||
// Helper to save to localStorage
|
||||
function saveToStorage(lists: QuoteList[]) {
|
||||
if (browser) {
|
||||
localStorage.setItem(LISTS_KEY, JSON.stringify(lists));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Create a new list
|
||||
createList: (name: string, description?: string) => {
|
||||
const newList: QuoteList = {
|
||||
id: `list-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name,
|
||||
description,
|
||||
quoteIds: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
update(lists => {
|
||||
const updated = [...lists, newList];
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
|
||||
return newList.id;
|
||||
},
|
||||
|
||||
// Update a list
|
||||
updateList: (id: string, updates: Partial<Omit<QuoteList, 'id' | 'createdAt'>>) => {
|
||||
update(lists => {
|
||||
const updated = lists.map(list =>
|
||||
list.id === id
|
||||
? { ...list, ...updates, updatedAt: Date.now() }
|
||||
: list
|
||||
);
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
|
||||
// Delete a list
|
||||
deleteList: (id: string) => {
|
||||
update(lists => {
|
||||
const updated = lists.filter(list => list.id !== id);
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
|
||||
// Add quote to list
|
||||
addQuoteToList: (listId: string, quoteId: string) => {
|
||||
update(lists => {
|
||||
const updated = lists.map(list => {
|
||||
if (list.id === listId && !list.quoteIds.includes(quoteId)) {
|
||||
return {
|
||||
...list,
|
||||
quoteIds: [...list.quoteIds, quoteId],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
return list;
|
||||
});
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
|
||||
// Remove quote from list
|
||||
removeQuoteFromList: (listId: string, quoteId: string) => {
|
||||
update(lists => {
|
||||
const updated = lists.map(list => {
|
||||
if (list.id === listId) {
|
||||
return {
|
||||
...list,
|
||||
quoteIds: list.quoteIds.filter(id => id !== quoteId),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
return list;
|
||||
});
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
|
||||
// Get a specific list
|
||||
getList: (id: string): QuoteList | undefined => {
|
||||
let foundList: QuoteList | undefined;
|
||||
subscribe(lists => {
|
||||
foundList = lists.find(list => list.id === id);
|
||||
})();
|
||||
return foundList;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const listsStore = createListsStore();
|
||||
24
apps/quote/packages/web-ui/src/stores/sidebar.ts
Normal file
24
apps/quote/packages/web-ui/src/stores/sidebar.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const SIDEBAR_KEY = 'sidebar-collapsed';
|
||||
|
||||
function createSidebarStore() {
|
||||
const stored = browser ? localStorage.getItem(SIDEBAR_KEY) === 'true' : false;
|
||||
const { subscribe, set, update } = writable<boolean>(stored);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
toggle: () => update(v => {
|
||||
const newValue = !v;
|
||||
if (browser) localStorage.setItem(SIDEBAR_KEY, String(newValue));
|
||||
return newValue;
|
||||
}),
|
||||
set: (value: boolean) => {
|
||||
if (browser) localStorage.setItem(SIDEBAR_KEY, String(value));
|
||||
set(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const isSidebarCollapsed = createSidebarStore();
|
||||
35
apps/quote/packages/web-ui/src/stores/theme.ts
Normal file
35
apps/quote/packages/web-ui/src/stores/theme.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
function createThemeStore() {
|
||||
const { subscribe, set, update } = writable<Theme>('light');
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
toggle: () => {
|
||||
update(current => {
|
||||
const newTheme = current === 'light' ? 'dark' : 'light';
|
||||
if (browser) {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
}
|
||||
return newTheme;
|
||||
});
|
||||
},
|
||||
init: () => {
|
||||
if (browser) {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||
set(initialTheme);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const theme = createThemeStore();
|
||||
51
apps/quote/packages/web-ui/src/stores/toast.ts
Normal file
51
apps/quote/packages/web-ui/src/stores/toast.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
|
||||
function addToast(message: string, type: ToastType = 'info', duration: number = 3000) {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const toast: Toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
duration,
|
||||
};
|
||||
|
||||
update(toasts => [...toasts, toast]);
|
||||
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeToast(id: string) {
|
||||
update(toasts => toasts.filter(t => t.id !== id));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
success: (message: string, duration?: number) => addToast(message, 'success', duration),
|
||||
error: (message: string, duration?: number) => addToast(message, 'error', duration),
|
||||
info: (message: string, duration?: number) => addToast(message, 'info', duration),
|
||||
warning: (message: string, duration?: number) => addToast(message, 'warning', duration),
|
||||
remove: removeToast,
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
2
apps/quote/packages/web-ui/src/styles/index.ts
Normal file
2
apps/quote/packages/web-ui/src/styles/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Export theme CSS path for importing in consuming apps
|
||||
export const themeCSS = './theme.css';
|
||||
157
apps/quote/packages/web-ui/src/styles/theme.css
Normal file
157
apps/quote/packages/web-ui/src/styles/theme.css
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/* Base Styles & Theme Variables */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Default Colors - Can be overridden per app */
|
||||
--color-primary: 102 126 234; /* #667eea */
|
||||
--color-primary-dark: 118 75 162; /* #764ba2 */
|
||||
--color-secondary: 236 72 153; /* #ec4899 */
|
||||
|
||||
/* Semantic Colors - Light Mode */
|
||||
--color-background: 255 255 255;
|
||||
--color-surface: 245 245 245;
|
||||
--color-surface-elevated: 255 255 255;
|
||||
|
||||
--color-text-primary: 51 51 51;
|
||||
--color-text-secondary: 102 102 102;
|
||||
--color-text-tertiary: 153 153 153;
|
||||
|
||||
--color-border: 224 224 224;
|
||||
--color-border-hover: 189 189 189;
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: 34 197 94;
|
||||
--color-warning: 234 179 8;
|
||||
--color-error: 239 68 68;
|
||||
--color-info: 59 130 246;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
[data-theme="dark"] {
|
||||
--color-background: 17 24 39; /* gray-900 */
|
||||
--color-surface: 31 41 55; /* gray-800 */
|
||||
--color-surface-elevated: 55 65 81; /* gray-700 */
|
||||
|
||||
--color-text-primary: 243 244 246; /* gray-100 */
|
||||
--color-text-secondary: 209 213 219; /* gray-300 */
|
||||
--color-text-tertiary: 156 163 175; /* gray-400 */
|
||||
|
||||
--color-border: 55 65 81; /* gray-700 */
|
||||
--color-border-hover: 75 85 99; /* gray-600 */
|
||||
|
||||
/* Adjust shadows for dark mode */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Apply theme colors to base elements */
|
||||
body {
|
||||
background-color: rgb(var(--color-background));
|
||||
color: rgb(var(--color-text-primary));
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
@layer components {
|
||||
/* Gradient backgrounds */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, rgb(var(--color-primary)) 0%, rgb(var(--color-primary-dark)) 100%);
|
||||
}
|
||||
|
||||
/* Glass effect */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .glass {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: rgb(var(--color-surface-elevated));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgb(var(--color-primary));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: rgb(var(--color-primary-dark));
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 2px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background-color: rgb(var(--color-background));
|
||||
color: rgb(var(--color-text-primary));
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue