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