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:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

@ -1,21 +1,21 @@
{
"name": "@quote/content",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./data": "./src/data/index.ts",
"./config": "./src/config/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@quote/shared": "workspace:*"
},
"devDependencies": {
"typescript": "~5.9.2"
}
"name": "@quote/content",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./data": "./src/data/index.ts",
"./config": "./src/config/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@quote/shared": "workspace:*"
},
"devDependencies": {
"typescript": "~5.9.2"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,22 +1,22 @@
{
"name": "@quote/shared",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./data": "./src/data/index.ts",
"./utils": "./src/utils/index.ts",
"./services": "./src/services/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src"
},
"devDependencies": {
"typescript": "~5.9.2"
},
"dependencies": {}
"name": "@quote/shared",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./data": "./src/data/index.ts",
"./utils": "./src/utils/index.ts",
"./services": "./src/services/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src"
},
"devDependencies": {
"typescript": "~5.9.2"
},
"dependencies": {}
}

View file

@ -5,96 +5,96 @@
import type { FullAppConfig, Quote } from '../types';
export const quotesAppConfig: FullAppConfig<Quote> = {
metadata: {
name: 'quotes',
displayName: 'Zitate',
description: 'Inspirierende Zitate von großen Denkern',
version: '1.0.0',
primaryLanguage: 'de',
supportedLanguages: ['de', 'en'],
},
metadata: {
name: 'quotes',
displayName: 'Zitate',
description: 'Inspirierende Zitate von großen Denkern',
version: '1.0.0',
primaryLanguage: 'de',
supportedLanguages: ['de', 'en'],
},
contentType: 'quote',
contentLabel: {
singular: 'Zitat',
plural: 'Zitate',
},
authorLabel: {
singular: 'Autor',
plural: 'Autoren',
},
contentType: 'quote',
contentLabel: {
singular: 'Zitat',
plural: 'Zitate',
},
authorLabel: {
singular: 'Autor',
plural: 'Autoren',
},
colors: {
primary: '#667eea',
secondary: '#764ba2',
accent: '#f093fb',
},
colors: {
primary: '#667eea',
secondary: '#764ba2',
accent: '#f093fb',
},
features: {
favorites: true,
lists: true,
sharing: true,
search: true,
filters: true,
authors: true,
categories: true,
tags: true,
dailyContent: true,
notifications: true,
widgets: true,
cloudSync: true,
premium: true,
},
features: {
favorites: true,
lists: true,
sharing: true,
search: true,
filters: true,
authors: true,
categories: true,
tags: true,
dailyContent: true,
notifications: true,
widgets: true,
cloudSync: true,
premium: true,
},
display: {
showAuthor: true,
showDate: false,
showSource: true,
showCategory: true,
showTags: true,
cardStyle: 'detailed',
swipeDirection: 'horizontal',
},
display: {
showAuthor: true,
showDate: false,
showSource: true,
showCategory: true,
showTags: true,
cardStyle: 'detailed',
swipeDirection: 'horizontal',
},
navigation: {
tabs: [
{
id: 'quotes',
label: 'Zitate',
icon: 'quote',
route: '/(tabs)',
enabled: true,
},
{
id: 'authors',
label: 'Autoren',
icon: 'person',
route: '/(tabs)/authors',
enabled: true,
},
{
id: 'lists',
label: 'Listen',
icon: 'list',
route: '/(tabs)/liste',
enabled: true,
},
{
id: 'favorites',
label: 'Favoriten',
icon: 'heart',
route: '/(tabs)/myquotes',
enabled: true,
},
{
id: 'search',
label: 'Suche',
icon: 'search',
route: '/(tabs)/search',
enabled: true,
},
],
showTabBar: true,
tabBarStyle: 'ios',
},
navigation: {
tabs: [
{
id: 'quotes',
label: 'Zitate',
icon: 'quote',
route: '/(tabs)',
enabled: true,
},
{
id: 'authors',
label: 'Autoren',
icon: 'person',
route: '/(tabs)/authors',
enabled: true,
},
{
id: 'lists',
label: 'Listen',
icon: 'list',
route: '/(tabs)/liste',
enabled: true,
},
{
id: 'favorites',
label: 'Favoriten',
icon: 'heart',
route: '/(tabs)/myquotes',
enabled: true,
},
{
id: 'search',
label: 'Suche',
icon: 'search',
route: '/(tabs)/search',
enabled: true,
},
],
showTabBar: true,
tabBarStyle: 'ios',
},
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -15,110 +15,111 @@ export type ContentType = 'quote' | 'proverb' | 'poem' | 'speech' | 'fable' | 't
* Color configuration for the app
*/
export interface AppColors {
primary: string;
secondary?: string;
accent?: string;
background?: string;
surface?: string;
text?: string;
primary: string;
secondary?: string;
accent?: string;
background?: string;
surface?: string;
text?: string;
}
/**
* Feature flags for the app
*/
export interface AppFeatures {
favorites?: boolean;
lists?: boolean;
sharing?: boolean;
search?: boolean;
filters?: boolean;
authors?: boolean;
categories?: boolean;
tags?: boolean;
dailyContent?: boolean;
notifications?: boolean;
widgets?: boolean;
cloudSync?: boolean;
premium?: boolean;
favorites?: boolean;
lists?: boolean;
sharing?: boolean;
search?: boolean;
filters?: boolean;
authors?: boolean;
categories?: boolean;
tags?: boolean;
dailyContent?: boolean;
notifications?: boolean;
widgets?: boolean;
cloudSync?: boolean;
premium?: boolean;
}
/**
* Content display configuration
*/
export interface ContentDisplayConfig {
showAuthor?: boolean;
showDate?: boolean;
showSource?: boolean;
showCategory?: boolean;
showTags?: boolean;
cardStyle?: 'minimal' | 'detailed' | 'magazine';
swipeDirection?: 'horizontal' | 'vertical';
showAuthor?: boolean;
showDate?: boolean;
showSource?: boolean;
showCategory?: boolean;
showTags?: boolean;
cardStyle?: 'minimal' | 'detailed' | 'magazine';
swipeDirection?: 'horizontal' | 'vertical';
}
/**
* App metadata
*/
export interface AppMetadata {
name: string;
displayName: string;
description: string;
version: string;
author?: string;
website?: string;
icon?: string;
primaryLanguage: string;
supportedLanguages: string[];
name: string;
displayName: string;
description: string;
version: string;
author?: string;
website?: string;
icon?: string;
primaryLanguage: string;
supportedLanguages: string[];
}
/**
* Main App Configuration
*/
export interface AppConfig<TContent extends ContentItem = ContentItem> {
// Basic Info
metadata: AppMetadata;
// Basic Info
metadata: AppMetadata;
// Content Type
contentType: ContentType;
contentLabel: {
singular: string;
plural: string;
};
authorLabel?: {
singular: string;
plural: string;
};
// Content Type
contentType: ContentType;
contentLabel: {
singular: string;
plural: string;
};
authorLabel?: {
singular: string;
plural: string;
};
// Branding
colors: AppColors;
// Branding
colors: AppColors;
// Features
features: AppFeatures;
// Features
features: AppFeatures;
// Display
display: ContentDisplayConfig;
// Display
display: ContentDisplayConfig;
// Custom fields (app-specific)
custom?: Record<string, any>;
// Custom fields (app-specific)
custom?: Record<string, any>;
}
/**
* Navigation configuration
*/
export interface NavigationConfig {
tabs: {
id: string;
label: string;
icon: string;
route: string;
enabled: boolean;
}[];
showTabBar?: boolean;
tabBarStyle?: 'ios' | 'android' | 'custom';
tabs: {
id: string;
label: string;
icon: string;
route: string;
enabled: boolean;
}[];
showTabBar?: boolean;
tabBarStyle?: 'ios' | 'android' | 'custom';
}
/**
* Complete app configuration including navigation
*/
export interface FullAppConfig<TContent extends ContentItem = ContentItem> extends AppConfig<TContent> {
navigation: NavigationConfig;
export interface FullAppConfig<TContent extends ContentItem = ContentItem>
extends AppConfig<TContent> {
navigation: NavigationConfig;
}

View file

@ -14,29 +14,29 @@
* Can represent quote authors, poets, speakers, etc.
*/
export interface ContentAuthor {
id: string;
name: string;
profession?: string[];
biography?: {
short: string;
long?: string;
sections?: { [key: string]: any };
keyAchievements?: string[];
famousQuote?: string;
};
lifespan?: {
birth: string;
death?: string;
};
verified?: boolean;
featured?: boolean;
imageUrl?: string;
image?: {
thumbnail?: string;
full?: string;
credit?: string;
source?: string;
};
id: string;
name: string;
profession?: string[];
biography?: {
short: string;
long?: string;
sections?: { [key: string]: any };
keyAchievements?: string[];
famousQuote?: string;
};
lifespan?: {
birth: string;
death?: string;
};
verified?: boolean;
featured?: boolean;
imageUrl?: string;
image?: {
thumbnail?: string;
full?: string;
credit?: string;
source?: string;
};
}
/**
@ -44,35 +44,35 @@ export interface ContentAuthor {
* Base type for all content (quotes, proverbs, poems, etc.)
*/
export interface ContentItem<TMetadata = Record<string, any>> {
id: string;
text: string;
authorId: string;
author?: ContentAuthor;
categories?: string[];
tags?: string[];
featured?: boolean;
likes?: number;
isFavorite?: boolean;
language?: string;
category?: string;
metadata?: TMetadata;
id: string;
text: string;
authorId: string;
author?: ContentAuthor;
categories?: string[];
tags?: string[];
featured?: boolean;
likes?: number;
isFavorite?: boolean;
language?: string;
category?: string;
metadata?: TMetadata;
}
/**
* Generic Category type
*/
export interface ContentCategory {
id: string;
name: string;
description?: string;
id: string;
name: string;
description?: string;
}
/**
* Generic Tag type
*/
export interface ContentTag {
id: string;
name: string;
id: string;
name: string;
}
// ============================================================================
@ -83,98 +83,98 @@ export interface ContentTag {
* Quote-specific metadata
*/
export interface QuoteMetadata {
source?: string;
year?: number;
context?: string;
verified?: boolean;
source?: string;
year?: number;
context?: string;
verified?: boolean;
}
/**
* Quote type - extends ContentItem with quote-specific metadata
*/
export interface Quote extends ContentItem<QuoteMetadata> {
source?: string;
year?: number;
source?: string;
year?: number;
}
/**
* Proverb-specific metadata
*/
export interface ProverbMetadata {
origin?: string; // e.g., "Deutsches Sprichwort", "Chinese Proverb"
meaning?: string; // Explanation of the proverb
variants?: string[]; // Different versions of the proverb
relatedProverbs?: string[]; // IDs of related proverbs
origin?: string; // e.g., "Deutsches Sprichwort", "Chinese Proverb"
meaning?: string; // Explanation of the proverb
variants?: string[]; // Different versions of the proverb
relatedProverbs?: string[]; // IDs of related proverbs
}
/**
* Proverb type - extends ContentItem with proverb-specific metadata
*/
export interface Proverb extends ContentItem<ProverbMetadata> {
origin?: string;
meaning?: string;
variants?: string[];
origin?: string;
meaning?: string;
variants?: string[];
}
/**
* Poem-specific metadata
*/
export interface PoemMetadata {
verses?: string[]; // Individual stanzas/verses
rhymeScheme?: string; // e.g., "ABAB", "AABB"
form?: string; // e.g., "Sonnet", "Haiku"
firstPublished?: number;
collection?: string; // Name of the poetry collection
verses?: string[]; // Individual stanzas/verses
rhymeScheme?: string; // e.g., "ABAB", "AABB"
form?: string; // e.g., "Sonnet", "Haiku"
firstPublished?: number;
collection?: string; // Name of the poetry collection
}
/**
* Poem type - extends ContentItem with poem-specific metadata
*/
export interface Poem extends ContentItem<PoemMetadata> {
verses?: string[];
rhymeScheme?: string;
form?: string;
verses?: string[];
rhymeScheme?: string;
form?: string;
}
/**
* Speech-specific metadata
*/
export interface SpeechMetadata {
date?: string; // Date of the speech
location?: string; // Where it was given
occasion?: string; // Context/occasion
audience?: string; // Who it was for
duration?: number; // Length in minutes
videoUrl?: string; // Link to video if available
date?: string; // Date of the speech
location?: string; // Where it was given
occasion?: string; // Context/occasion
audience?: string; // Who it was for
duration?: number; // Length in minutes
videoUrl?: string; // Link to video if available
}
/**
* Historical Speech type
*/
export interface Speech extends ContentItem<SpeechMetadata> {
date?: string;
location?: string;
occasion?: string;
date?: string;
location?: string;
occasion?: string;
}
/**
* Fable-specific metadata
*/
export interface FableMetadata {
moral?: string; // The moral/lesson of the fable
characters?: string[]; // Main characters (e.g., "Rabe", "Fuchs")
setting?: string; // Where the fable takes place
length?: 'short' | 'medium' | 'long'; // Length classification
collection?: string; // Collection name if part of one
moral?: string; // The moral/lesson of the fable
characters?: string[]; // Main characters (e.g., "Rabe", "Fuchs")
setting?: string; // Where the fable takes place
length?: 'short' | 'medium' | 'long'; // Length classification
collection?: string; // Collection name if part of one
}
/**
* Fable type - extends ContentItem with fable-specific metadata
*/
export interface Fable extends ContentItem<FableMetadata> {
moral?: string;
characters?: string[];
setting?: string;
moral?: string;
characters?: string[];
setting?: string;
}
// ============================================================================

View file

@ -14,16 +14,16 @@ import type { ContentItem, ContentAuthor } from '../types';
* Format author/creator name
*/
export function formatAuthorName(author: ContentAuthor): string {
return author.name;
return author.name;
}
/**
* Get author/creator lifespan string
*/
export function getAuthorLifespan(author: ContentAuthor): string {
if (!author.lifespan) return '';
const { birth, death } = author.lifespan;
return death ? `${birth} - ${death}` : `${birth} - Present`;
if (!author.lifespan) return '';
const { birth, death } = author.lifespan;
return death ? `${birth} - ${death}` : `${birth} - Present`;
}
// ============================================================================
@ -33,124 +33,98 @@ export function getAuthorLifespan(author: ContentAuthor): string {
/**
* Filter content items by category
*/
export function filterContentByCategory<T extends ContentItem>(
items: T[],
category: string
): T[] {
return items.filter(item =>
item.category === category ||
item.categories?.includes(category)
);
export function filterContentByCategory<T extends ContentItem>(items: T[], category: string): T[] {
return items.filter((item) => item.category === category || item.categories?.includes(category));
}
/**
* Filter content items by tag
*/
export function filterContentByTag<T extends ContentItem>(
items: T[],
tag: string
): T[] {
return items.filter(item => item.tags?.includes(tag));
export function filterContentByTag<T extends ContentItem>(items: T[], tag: string): T[] {
return items.filter((item) => item.tags?.includes(tag));
}
/**
* Filter content items by author
*/
export function filterContentByAuthor<T extends ContentItem>(
items: T[],
authorId: string
): T[] {
return items.filter(item => item.authorId === authorId);
export function filterContentByAuthor<T extends ContentItem>(items: T[], authorId: string): T[] {
return items.filter((item) => item.authorId === authorId);
}
/**
* Get random content item from array
*/
export function getRandomContent<T extends ContentItem>(
items: T[]
): T | undefined {
if (items.length === 0) return undefined;
return items[Math.floor(Math.random() * items.length)];
export function getRandomContent<T extends ContentItem>(items: T[]): T | undefined {
if (items.length === 0) return undefined;
return items[Math.floor(Math.random() * items.length)];
}
/**
* Search content items by text
*/
export function searchContent<T extends ContentItem>(
items: T[],
searchTerm: string
): T[] {
const term = searchTerm.toLowerCase();
return items.filter(item =>
item.text.toLowerCase().includes(term) ||
item.tags?.some(tag => tag.toLowerCase().includes(term))
);
export function searchContent<T extends ContentItem>(items: T[], searchTerm: string): T[] {
const term = searchTerm.toLowerCase();
return items.filter(
(item) =>
item.text.toLowerCase().includes(term) ||
item.tags?.some((tag) => tag.toLowerCase().includes(term))
);
}
/**
* Get unique categories from content items
*/
export function getUniqueCategories<T extends ContentItem>(
items: T[]
): string[] {
const categories = new Set<string>();
items.forEach(item => {
if (item.category) categories.add(item.category);
item.categories?.forEach(cat => categories.add(cat));
});
return Array.from(categories).sort();
export function getUniqueCategories<T extends ContentItem>(items: T[]): string[] {
const categories = new Set<string>();
items.forEach((item) => {
if (item.category) categories.add(item.category);
item.categories?.forEach((cat) => categories.add(cat));
});
return Array.from(categories).sort();
}
/**
* Get unique tags from content items
*/
export function getUniqueTags<T extends ContentItem>(
items: T[]
): string[] {
const tags = new Set<string>();
items.forEach(item => {
item.tags?.forEach(tag => tags.add(tag));
});
return Array.from(tags).sort();
export function getUniqueTags<T extends ContentItem>(items: T[]): string[] {
const tags = new Set<string>();
items.forEach((item) => {
item.tags?.forEach((tag) => tags.add(tag));
});
return Array.from(tags).sort();
}
/**
* Group content items by category
*/
export function groupContentByCategory<T extends ContentItem>(
items: T[]
): Record<string, T[]> {
const grouped: Record<string, T[]> = {};
export function groupContentByCategory<T extends ContentItem>(items: T[]): Record<string, T[]> {
const grouped: Record<string, T[]> = {};
items.forEach(item => {
const categories = item.categories || (item.category ? [item.category] : ['uncategorized']);
categories.forEach(category => {
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(item);
});
});
items.forEach((item) => {
const categories = item.categories || (item.category ? [item.category] : ['uncategorized']);
categories.forEach((category) => {
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(item);
});
});
return grouped;
return grouped;
}
/**
* Get featured content items
*/
export function getFeaturedContent<T extends ContentItem>(
items: T[]
): T[] {
return items.filter(item => item.featured === true);
export function getFeaturedContent<T extends ContentItem>(items: T[]): T[] {
return items.filter((item) => item.featured === true);
}
/**
* Get favorite content items
*/
export function getFavoriteContent<T extends ContentItem>(
items: T[]
): T[] {
return items.filter(item => item.isFavorite === true);
export function getFavoriteContent<T extends ContentItem>(items: T[]): T[] {
return items.filter((item) => item.isFavorite === true);
}
// ============================================================================

View file

@ -1,20 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,26 +1,26 @@
{
"name": "@quote/web-ui",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./styles": "./src/styles/index.ts",
"./components/*": "./src/components/*.svelte",
"./stores/*": "./src/stores/*.ts"
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"dependencies": {
"@quote/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0"
}
"name": "@quote/web-ui",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./styles": "./src/styles/index.ts",
"./components/*": "./src/components/*.svelte",
"./stores/*": "./src/stores/*.ts"
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"dependencies": {
"@quote/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -1,62 +1,62 @@
import type { Config } from 'tailwindcss';
export default {
content: [], // Will be extended by consuming apps
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
dark: 'rgb(var(--color-primary-dark) / <alpha-value>)',
},
secondary: 'rgb(var(--color-secondary) / <alpha-value>)',
background: 'rgb(var(--color-background) / <alpha-value>)',
surface: {
DEFAULT: 'rgb(var(--color-surface) / <alpha-value>)',
elevated: 'rgb(var(--color-surface-elevated) / <alpha-value>)',
},
text: {
primary: 'rgb(var(--color-text-primary) / <alpha-value>)',
secondary: 'rgb(var(--color-text-secondary) / <alpha-value>)',
tertiary: 'rgb(var(--color-text-tertiary) / <alpha-value>)',
},
border: {
DEFAULT: 'rgb(var(--color-border) / <alpha-value>)',
hover: 'rgb(var(--color-border-hover) / <alpha-value>)',
},
success: 'rgb(var(--color-success) / <alpha-value>)',
warning: 'rgb(var(--color-warning) / <alpha-value>)',
error: 'rgb(var(--color-error) / <alpha-value>)',
info: 'rgb(var(--color-info) / <alpha-value>)',
},
spacing: {
xs: 'var(--spacing-xs)',
sm: 'var(--spacing-sm)',
md: 'var(--spacing-md)',
lg: 'var(--spacing-lg)',
xl: 'var(--spacing-xl)',
'2xl': 'var(--spacing-2xl)',
},
borderRadius: {
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
full: 'var(--radius-full)',
},
boxShadow: {
sm: 'var(--shadow-sm)',
md: 'var(--shadow-md)',
lg: 'var(--shadow-lg)',
xl: 'var(--shadow-xl)',
},
transitionDuration: {
fast: '150ms',
base: '200ms',
slow: '300ms',
},
},
},
plugins: [],
content: [], // Will be extended by consuming apps
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
dark: 'rgb(var(--color-primary-dark) / <alpha-value>)',
},
secondary: 'rgb(var(--color-secondary) / <alpha-value>)',
background: 'rgb(var(--color-background) / <alpha-value>)',
surface: {
DEFAULT: 'rgb(var(--color-surface) / <alpha-value>)',
elevated: 'rgb(var(--color-surface-elevated) / <alpha-value>)',
},
text: {
primary: 'rgb(var(--color-text-primary) / <alpha-value>)',
secondary: 'rgb(var(--color-text-secondary) / <alpha-value>)',
tertiary: 'rgb(var(--color-text-tertiary) / <alpha-value>)',
},
border: {
DEFAULT: 'rgb(var(--color-border) / <alpha-value>)',
hover: 'rgb(var(--color-border-hover) / <alpha-value>)',
},
success: 'rgb(var(--color-success) / <alpha-value>)',
warning: 'rgb(var(--color-warning) / <alpha-value>)',
error: 'rgb(var(--color-error) / <alpha-value>)',
info: 'rgb(var(--color-info) / <alpha-value>)',
},
spacing: {
xs: 'var(--spacing-xs)',
sm: 'var(--spacing-sm)',
md: 'var(--spacing-md)',
lg: 'var(--spacing-lg)',
xl: 'var(--spacing-xl)',
'2xl': 'var(--spacing-2xl)',
},
borderRadius: {
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
full: 'var(--radius-full)',
},
boxShadow: {
sm: 'var(--shadow-sm)',
md: 'var(--shadow-md)',
lg: 'var(--shadow-lg)',
xl: 'var(--shadow-xl)',
},
transitionDuration: {
fast: '150ms',
base: '200ms',
slow: '300ms',
},
},
},
plugins: [],
} satisfies Config;

View file

@ -1,20 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"paths": {
"@quote/shared": ["../shared/src/index.ts"],
"@quote/web-ui": ["./src/index.ts"],
"@quote/web-ui/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.svelte"],
"exclude": ["node_modules"]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"paths": {
"@quote/shared": ["../shared/src/index.ts"],
"@quote/web-ui": ["./src/index.ts"],
"@quote/web-ui/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.svelte"],
"exclude": ["node_modules"]
}