mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 21:46:42 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,503 +1,518 @@
|
|||
<script lang="ts" generics="T extends ContentItem">
|
||||
import type { ContentItem, AppConfig } from '@quote/shared';
|
||||
import ContentCard from './ContentCard.svelte';
|
||||
import { toast } from '../stores/toast';
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
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);
|
||||
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);
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
// 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 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))];
|
||||
// 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;
|
||||
})
|
||||
);
|
||||
// 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)
|
||||
);
|
||||
// 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);
|
||||
// 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 toggleSearch() {
|
||||
isSearchOpen = !isSearchOpen;
|
||||
if (!isSearchOpen) {
|
||||
searchTerm = '';
|
||||
selectedCategory = 'all';
|
||||
currentPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
isLoadingMore = true;
|
||||
setTimeout(() => {
|
||||
currentPage++;
|
||||
isLoadingMore = false;
|
||||
}, 300);
|
||||
}
|
||||
function loadMore() {
|
||||
isLoadingMore = true;
|
||||
setTimeout(() => {
|
||||
currentPage++;
|
||||
isLoadingMore = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Reset page when search/filter changes
|
||||
$effect(() => {
|
||||
searchTerm;
|
||||
selectedCategory;
|
||||
currentPage = 1;
|
||||
});
|
||||
// Reset page when search/filter changes
|
||||
$effect(() => {
|
||||
searchTerm;
|
||||
selectedCategory;
|
||||
currentPage = 1;
|
||||
});
|
||||
|
||||
function handleToggleFavorite(event: CustomEvent) {
|
||||
const { contentId } = event.detail;
|
||||
const wasAdded = !favorites.has(contentId);
|
||||
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);
|
||||
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]));
|
||||
}
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
// 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}`;
|
||||
}
|
||||
}
|
||||
function handleAuthorClick(event: CustomEvent) {
|
||||
const { authorId } = event.detail;
|
||||
if (authorId) {
|
||||
window.location.href = `/authors/${authorId}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle} - {config.metadata.displayName}</title>
|
||||
<title>{pageTitle} - {config.metadata.displayName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="browse-page">
|
||||
<div class="header-container">
|
||||
<div class="header-row">
|
||||
<h2>{pageTitle}</h2>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
{#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>
|
||||
<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>
|
||||
{#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}
|
||||
<!-- 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}
|
||||
{#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);
|
||||
}
|
||||
.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-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);
|
||||
}
|
||||
.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));
|
||||
}
|
||||
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 {
|
||||
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:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.search-fab:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@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 {
|
||||
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));
|
||||
}
|
||||
.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 {
|
||||
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));
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@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 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-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 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;
|
||||
}
|
||||
.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 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 {
|
||||
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: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;
|
||||
}
|
||||
.load-more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.browse-page {
|
||||
padding-bottom: var(--spacing-xl);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.browse-page {
|
||||
padding-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 100%;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
.header-container {
|
||||
max-width: 100%;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.header-row {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
.header-row {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.search-fab {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
.search-fab {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
.search-bar {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.category-filter {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
gap: var(--spacing-lg);
|
||||
max-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);
|
||||
}
|
||||
.floating-results {
|
||||
bottom: 5rem;
|
||||
font-size: 0.8125rem;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
.empty-state {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,73 +1,77 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
categories: string[];
|
||||
selectedCategory: string | null;
|
||||
onSelect: (category: string | null) => void;
|
||||
primaryColor?: string;
|
||||
}
|
||||
interface Props {
|
||||
categories: string[];
|
||||
selectedCategory: string | null;
|
||||
onSelect: (category: string | null) => void;
|
||||
primaryColor?: string;
|
||||
}
|
||||
|
||||
let { categories, selectedCategory = $bindable(null), onSelect, primaryColor }: Props = $props();
|
||||
let { categories, selectedCategory = $bindable(null), onSelect, primaryColor }: Props = $props();
|
||||
|
||||
function handleSelect(category: string | null) {
|
||||
selectedCategory = category;
|
||||
onSelect(category);
|
||||
}
|
||||
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}
|
||||
<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-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 {
|
||||
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: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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.category-filters {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,398 +1,431 @@
|
|||
<script lang="ts" generics="T extends ContentItem">
|
||||
import type { ContentItem } from '@quote/shared';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { toast } from '../stores/toast';
|
||||
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;
|
||||
}
|
||||
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();
|
||||
let {
|
||||
content,
|
||||
variant = 'simple',
|
||||
category,
|
||||
showAuthor = true,
|
||||
showSource = true,
|
||||
gradientStyle,
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
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%)',
|
||||
};
|
||||
// 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()];
|
||||
}
|
||||
if (cat && gradients[cat.toLowerCase()]) {
|
||||
return gradients[cat.toLowerCase()];
|
||||
}
|
||||
|
||||
// Default gradient
|
||||
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
}
|
||||
// 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 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}`;
|
||||
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();
|
||||
}
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: 'Content',
|
||||
text: text,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.name !== 'AbortError') {
|
||||
handleCopy();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
handleCopy();
|
||||
}
|
||||
|
||||
dispatch('share', { content });
|
||||
}
|
||||
dispatch('share', { content });
|
||||
}
|
||||
|
||||
function handleFavorite() {
|
||||
dispatch('toggleFavorite', { contentId: content.id });
|
||||
}
|
||||
function handleFavorite() {
|
||||
dispatch('toggleFavorite', { contentId: content.id });
|
||||
}
|
||||
|
||||
function handleAuthorClick() {
|
||||
dispatch('authorClick', { authorId: content.author?.id || content.authorId });
|
||||
}
|
||||
function handleAuthorClick() {
|
||||
dispatch('authorClick', { authorId: content.author?.id || content.authorId });
|
||||
}
|
||||
|
||||
let showCopySuccess = $state(false);
|
||||
let showCopySuccess = $state(false);
|
||||
|
||||
function showCopyFeedback() {
|
||||
showCopySuccess = true;
|
||||
setTimeout(() => {
|
||||
showCopySuccess = false;
|
||||
}, 2000);
|
||||
}
|
||||
function showCopyFeedback() {
|
||||
showCopySuccess = true;
|
||||
setTimeout(() => {
|
||||
showCopySuccess = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const finalGradient = gradientStyle || getCategoryGradient(category || content.categories?.[0]);
|
||||
const isDaily = variant === 'daily';
|
||||
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>
|
||||
<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}
|
||||
<!-- 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}
|
||||
<!-- 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}
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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 {
|
||||
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.daily {
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.content-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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 {
|
||||
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 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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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 {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.author-info:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.author-info:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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-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 {
|
||||
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:hover {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.action-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.favorite-btn.is-favorite {
|
||||
color: #ff6b9d;
|
||||
}
|
||||
.favorite-btn.is-favorite {
|
||||
color: #ff6b9d;
|
||||
}
|
||||
|
||||
.favorite-btn.is-favorite:hover {
|
||||
color: #ff4081;
|
||||
}
|
||||
.favorite-btn.is-favorite:hover {
|
||||
color: #ff4081;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.content-text p {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.content-text p {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.action-buttons {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
.action-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,317 +1,347 @@
|
|||
<script lang="ts">
|
||||
interface AppInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
url: string;
|
||||
features: string[];
|
||||
}
|
||||
interface AppInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
url: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentAppName: string;
|
||||
pageTitle?: string;
|
||||
}
|
||||
interface Props {
|
||||
currentAppName: string;
|
||||
pageTitle?: string;
|
||||
}
|
||||
|
||||
let { currentAppName, pageTitle = 'Apps entdecken' }: Props = $props();
|
||||
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 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));
|
||||
const otherApps = $derived(allApps.filter((app) => app.name !== currentAppName));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
<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="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="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-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-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="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 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);
|
||||
}
|
||||
.discover-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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 {
|
||||
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::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 {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: rgb(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.app-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.icon-emoji {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
}
|
||||
.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-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-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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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-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 {
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.app-card:hover .app-cta svg {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.app-card:hover .app-cta svg {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.app-cta svg {
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
.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 {
|
||||
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 svg {
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--color-primary));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.apps-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
.apps-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.app-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
.app-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.app-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.icon-emoji {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.icon-emoji {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.app-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.app-description {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
.info-box {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.info-box svg {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
.info-box svg {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,159 +1,163 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: any;
|
||||
}
|
||||
interface Props {
|
||||
children?: any;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
let { children }: Props = $props();
|
||||
|
||||
let hasError = $state(false);
|
||||
let errorMessage = $state('');
|
||||
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';
|
||||
};
|
||||
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';
|
||||
};
|
||||
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);
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
});
|
||||
|
||||
function handleReset() {
|
||||
hasError = false;
|
||||
errorMessage = '';
|
||||
window.location.reload();
|
||||
}
|
||||
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>
|
||||
<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?.()}
|
||||
{@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-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-container {
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin: 0 auto var(--spacing-lg);
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
.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;
|
||||
}
|
||||
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-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;
|
||||
}
|
||||
.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 {
|
||||
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 {
|
||||
background: rgb(var(--color-primary));
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.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 {
|
||||
background: rgb(var(--color-surface));
|
||||
color: rgb(var(--color-text-primary));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgb(var(--color-background));
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: rgb(var(--color-background));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-boundary {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.error-boundary {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,53 +1,53 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
primaryColor?: string;
|
||||
primaryDarkColor?: string;
|
||||
}
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
primaryColor?: string;
|
||||
primaryDarkColor?: string;
|
||||
}
|
||||
|
||||
let { title, subtitle, primaryColor, primaryDarkColor }: Props = $props();
|
||||
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;`;
|
||||
});
|
||||
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}
|
||||
<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);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,66 +1,69 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onInput?: (value: string) => void;
|
||||
}
|
||||
interface Props {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onInput?: (value: string) => void;
|
||||
}
|
||||
|
||||
let { value = $bindable(''), placeholder = 'Suchen...', onInput }: Props = $props();
|
||||
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);
|
||||
}
|
||||
}
|
||||
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}
|
||||
/>
|
||||
<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} oninput={handleInput} {placeholder} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
.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 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 {
|
||||
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:focus {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: rgb(var(--color-text-tertiary));
|
||||
}
|
||||
.search-box input::placeholder {
|
||||
color: rgb(var(--color-text-tertiary));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,191 +1,198 @@
|
|||
<script lang="ts">
|
||||
import { toast, type Toast } from '../stores/toast';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { toast, type Toast } from '../stores/toast';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
toast.subscribe(value => {
|
||||
toasts = value;
|
||||
});
|
||||
toast.subscribe((value) => {
|
||||
toasts = value;
|
||||
});
|
||||
|
||||
function handleClose(id: string) {
|
||||
toast.remove(id);
|
||||
}
|
||||
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">
|
||||
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">
|
||||
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">
|
||||
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">
|
||||
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}
|
||||
{#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-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 {
|
||||
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-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid rgb(var(--color-success));
|
||||
}
|
||||
.toast-success {
|
||||
border-left: 4px solid rgb(var(--color-success));
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: 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 {
|
||||
border-left: 4px solid rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: 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 {
|
||||
border-left: 4px solid rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: 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 {
|
||||
border-left: 4px solid rgb(var(--color-info));
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: 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-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 {
|
||||
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));
|
||||
}
|
||||
.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;
|
||||
}
|
||||
/* 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 {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
.toast-message {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for progress bar (optional enhancement) */
|
||||
@keyframes shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
/* Animation for progress bar (optional enhancement) */
|
||||
@keyframes shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,121 +2,119 @@ 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;
|
||||
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) || '[]')
|
||||
: [];
|
||||
// Load initial data from localStorage
|
||||
const initialLists: QuoteList[] = browser
|
||||
? JSON.parse(localStorage.getItem(LISTS_KEY) || '[]')
|
||||
: [];
|
||||
|
||||
const { subscribe, set, update } = writable<QuoteList[]>(initialLists);
|
||||
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));
|
||||
}
|
||||
}
|
||||
// Helper to save to localStorage
|
||||
function saveToStorage(lists: QuoteList[]) {
|
||||
if (browser) {
|
||||
localStorage.setItem(LISTS_KEY, JSON.stringify(lists));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
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(),
|
||||
};
|
||||
// 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;
|
||||
});
|
||||
update((lists) => {
|
||||
const updated = [...lists, newList];
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
|
||||
return newList.id;
|
||||
},
|
||||
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;
|
||||
});
|
||||
},
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -4,21 +4,22 @@ 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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -4,32 +4,32 @@ import { browser } from '$app/environment';
|
|||
export type Theme = 'light' | 'dark';
|
||||
|
||||
function createThemeStore() {
|
||||
const { subscribe, set, update } = writable<Theme>('light');
|
||||
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');
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||
set(initialTheme);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const theme = createThemeStore();
|
||||
|
|
|
|||
|
|
@ -3,49 +3,49 @@ import { writable } from 'svelte/store';
|
|||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration: number;
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
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)}`;
|
||||
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,
|
||||
};
|
||||
const toast: Toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
duration,
|
||||
};
|
||||
|
||||
update(toasts => [...toasts, toast]);
|
||||
update((toasts) => [...toasts, toast]);
|
||||
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeToast(id: string) {
|
||||
update(toasts => toasts.filter(t => t.id !== 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,
|
||||
};
|
||||
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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue