feat: integrate uload and picture, unify package naming

- Add uload project with apps/web structure
  - Reorganize from flat to monorepo structure
  - Remove PocketBase binary and local data
  - Update to pnpm and @uload/web namespace

- Add picture project to monorepo
  - Remove embedded git repository

- Unify all package names to @{project}/{app} schema:
  - @maerchenzauber/* (was @storyteller/*)
  - @manacore/* (was manacore-*, manacore)
  - @manadeck/* (was web, backend, manadeck)
  - @memoro/* (was memoro-web, landing, memoro)
  - @picture/* (already unified)
  - @uload/web

- Add convenient dev scripts for all apps:
  - pnpm dev:{project}:web
  - pnpm dev:{project}:landing
  - pnpm dev:{project}:mobile
  - pnpm dev:{project}:backend

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 04:00:36 +01:00
parent c6c4c5a552
commit c712a2504a
1031 changed files with 189301 additions and 290 deletions

View file

@ -0,0 +1,12 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
/* Scan shared packages for Tailwind classes */
@source '../../../../packages/shared-ui/src';
@source '../../../../packages/shared-auth-ui/src';
@source '../../../../packages/shared-branding/src';
@source '../../../../packages/shared-theme-ui/src';
@source '../../../../packages/shared-subscription-ui/src';
@source '../../../../packages/shared-i18n/src';

13
picture/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,85 @@
import posthog from 'posthog-js';
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
export function initPostHog() {
const posthogKey = env.PUBLIC_POSTHOG_KEY;
const posthogHost = env.PUBLIC_POSTHOG_HOST;
if (browser && posthogKey && posthogHost) {
posthog.init(posthogKey, {
api_host: posthogHost,
person_profiles: 'identified_only', // Only track identified users
capture_pageview: true, // Automatically capture pageviews
capture_pageleave: true, // Track when users leave pages
// Privacy-friendly settings
opt_out_capturing_by_default: false,
persistence: 'localStorage',
autocapture: false, // Disable automatic event capture for better control
// Session recording (optional - can be disabled)
disable_session_recording: true, // Set to false if you want session recordings
// Performance
loaded: (posthog) => {
if (import.meta.env.DEV) {
console.log('PostHog loaded');
}
}
});
}
}
// Helper functions for common tracking events
export const analytics = {
// Track page view (usually automatic, but available for manual tracking)
pageView: (url?: string) => {
if (browser) {
posthog.capture('$pageview', { url: url || window.location.href });
}
},
// Identify a user
identify: (userId: string, properties?: Record<string, any>) => {
if (browser) {
posthog.identify(userId, properties);
}
},
// Track custom events
track: (eventName: string, properties?: Record<string, any>) => {
if (browser) {
posthog.capture(eventName, properties);
}
},
// Reset user identity (e.g., on logout)
reset: () => {
if (browser) {
posthog.reset();
}
},
// Set user properties
setUserProperties: (properties: Record<string, any>) => {
if (browser) {
posthog.setPersonProperties(properties);
}
},
// Feature flags
isFeatureEnabled: (featureKey: string): boolean => {
if (browser) {
return posthog.isFeatureEnabled(featureKey) ?? false;
}
return false;
},
// Get feature flag value
getFeatureFlag: (featureKey: string): string | boolean | undefined => {
if (browser) {
return posthog.getFeatureFlag(featureKey);
}
return undefined;
}
};
export default posthog;

View file

@ -0,0 +1,411 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
type BoardItemRow = Database['public']['Tables']['board_items']['Row'];
type BoardItemInsert = Database['public']['Tables']['board_items']['Insert'];
type BoardItemUpdate = Database['public']['Tables']['board_items']['Update'];
// ===== BASE TYPES =====
interface BoardItemBase {
id: string;
board_id: string;
item_type: 'image' | 'text';
position_x: number;
position_y: number;
scale_x: number;
scale_y: number;
rotation: number;
z_index: number;
opacity: number;
width: number | null;
height: number | null;
properties: Record<string, any>;
created_at: string;
}
// ===== IMAGE ITEM =====
export interface BoardImageItem extends BoardItemBase {
item_type: 'image';
image_id: string;
text_content: null;
font_size: null;
color: null;
image?: {
id: string;
public_url: string;
width: number | null;
height: number | null;
prompt: string | null;
blurhash: string | null;
};
}
// ===== TEXT ITEM =====
export interface TextProperties {
fontFamily?: string;
fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic';
textAlign?: 'left' | 'center' | 'right';
lineHeight?: number;
letterSpacing?: number;
backgroundColor?: string;
padding?: number;
}
export interface BoardTextItem extends BoardItemBase {
item_type: 'text';
image_id: null;
text_content: string;
font_size: number;
color: string;
properties: TextProperties;
}
// ===== DISCRIMINATED UNION =====
export type BoardItem = BoardImageItem | BoardTextItem;
// ===== TYPE GUARDS =====
export function isImageItem(item: BoardItem): item is BoardImageItem {
return item.item_type === 'image';
}
export function isTextItem(item: BoardItem): item is BoardTextItem {
return item.item_type === 'text';
}
// ===== LEGACY (for backwards compatibility) =====
export interface BoardItemWithImage extends BoardImageItem {
image: {
id: string;
public_url: string;
width: number | null;
height: number | null;
prompt: string | null;
blurhash: string | null;
};
}
// ===== HELPER FUNCTIONS =====
async function getNextZIndex(boardId: string): Promise<number> {
const { data: maxZIndex, error } = await supabase
.from('board_items')
.select('z_index')
.eq('board_id', boardId)
.order('z_index', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return (maxZIndex?.z_index ?? -1) + 1;
}
/**
* Get all items for a board (images and texts)
*/
export async function getBoardItems(boardId: string): Promise<BoardItem[]> {
const { data, error } = await supabase
.from('board_items')
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.eq('board_id', boardId)
.order('z_index', { ascending: true });
if (error) throw error;
return data as BoardItem[];
}
/**
* Add an image to a board
*/
export async function addImageToBoard(params: {
boardId: string;
imageId: string;
position?: { x: number; y: number };
}): Promise<BoardImageItem> {
const { boardId, imageId, position = { x: 100, y: 100 } } = params;
const item: BoardItemInsert = {
board_id: boardId,
item_type: 'image',
image_id: imageId,
position_x: position.x,
position_y: position.y,
z_index: await getNextZIndex(boardId)
};
const { data, error } = await supabase
.from('board_items')
.insert(item)
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.single();
if (error) throw error;
return data as BoardImageItem;
}
/**
* Add text to a board
*/
export async function addTextToBoard(params: {
boardId: string;
content?: string;
position?: { x: number; y: number };
fontSize?: number;
color?: string;
fontFamily?: string;
}): Promise<BoardTextItem> {
const {
boardId,
content = 'Doppelklick zum Bearbeiten',
position = { x: 100, y: 100 },
fontSize = 24,
color = '#000000',
fontFamily = 'Arial'
} = params;
const item: BoardItemInsert = {
board_id: boardId,
item_type: 'text',
text_content: content,
font_size: fontSize,
color: color,
position_x: position.x,
position_y: position.y,
width: 300, // Default text box width
z_index: await getNextZIndex(boardId),
properties: {
fontFamily,
fontWeight: 'normal',
fontStyle: 'normal',
textAlign: 'left',
lineHeight: 1.2
}
};
const { data, error } = await supabase
.from('board_items')
.insert(item)
.select()
.single();
if (error) throw error;
return data as BoardTextItem;
}
/**
* Legacy function for backwards compatibility
*/
export async function addBoardItem(item: BoardItemInsert) {
const nextZIndex = await getNextZIndex(item.board_id);
const { data, error } = await supabase
.from('board_items')
.insert({
...item,
z_index: nextZIndex
})
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.single();
if (error) throw error;
return data as BoardItem;
}
/**
* Update a board item (position, scale, rotation, text content, etc.)
*/
export async function updateBoardItem(id: string, updates: BoardItemUpdate): Promise<BoardItem> {
const { data, error } = await supabase
.from('board_items')
.update(updates)
.eq('id', id)
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.single();
if (error) throw error;
return data as BoardItem;
}
/**
* Update multiple board items at once (for batch operations)
*/
export async function updateBoardItems(items: Array<{ id: string } & BoardItemUpdate>) {
const promises = items.map(({ id, ...updates }) =>
supabase
.from('board_items')
.update(updates)
.eq('id', id)
);
const results = await Promise.all(promises);
const errors = results.filter(r => r.error).map(r => r.error);
if (errors.length > 0) throw errors[0];
}
/**
* Remove an item from a board
*/
export async function removeBoardItem(id: string) {
const { error } = await supabase
.from('board_items')
.delete()
.eq('id', id);
if (error) throw error;
}
/**
* Remove multiple items from a board
*/
export async function removeBoardItems(ids: string[]) {
const { error } = await supabase
.from('board_items')
.delete()
.in('id', ids);
if (error) throw error;
}
/**
* Change z-index (layer order) of an item
*/
export async function changeBoardItemZIndex(id: string, direction: 'up' | 'down' | 'top' | 'bottom') {
// Get current item
const { data: currentItem, error: currentError } = await supabase
.from('board_items')
.select('*')
.eq('id', id)
.single();
if (currentError) throw currentError;
// Get all items in the same board
const { data: allItems, error: allError } = await supabase
.from('board_items')
.select('id, z_index')
.eq('board_id', currentItem.board_id)
.order('z_index', { ascending: true });
if (allError) throw allError;
const currentIndex = allItems.findIndex(item => item.id === id);
let newZIndex = currentItem.z_index;
switch (direction) {
case 'up':
if (currentIndex < allItems.length - 1) {
newZIndex = allItems[currentIndex + 1].z_index;
// Swap z-indexes
await supabase
.from('board_items')
.update({ z_index: currentItem.z_index })
.eq('id', allItems[currentIndex + 1].id);
}
break;
case 'down':
if (currentIndex > 0) {
newZIndex = allItems[currentIndex - 1].z_index;
// Swap z-indexes
await supabase
.from('board_items')
.update({ z_index: currentItem.z_index })
.eq('id', allItems[currentIndex - 1].id);
}
break;
case 'top':
newZIndex = allItems[allItems.length - 1].z_index + 1;
break;
case 'bottom':
newZIndex = allItems[0].z_index - 1;
break;
}
// Update current item
return updateBoardItem(id, { z_index: newZIndex });
}
/**
* Get a single board item by ID
*/
export async function getBoardItemById(id: string): Promise<BoardItem> {
const { data, error } = await supabase
.from('board_items')
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.eq('id', id)
.single();
if (error) throw error;
return data as BoardItem;
}
/**
* Check if an image is already on a board
*/
export async function isImageOnBoard(boardId: string, imageId: string) {
const { data, error } = await supabase
.from('board_items')
.select('id')
.eq('board_id', boardId)
.eq('image_id', imageId)
.maybeSingle();
if (error) throw error;
return !!data;
}

View file

@ -0,0 +1,226 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
type Board = Database['public']['Tables']['boards']['Row'];
type BoardInsert = Database['public']['Tables']['boards']['Insert'];
type BoardUpdate = Database['public']['Tables']['boards']['Update'];
export interface BoardWithCount extends Board {
item_count: number;
}
export interface GetBoardsParams {
userId: string;
page?: number;
limit?: number;
includePublic?: boolean;
}
/**
* Get all boards for a user with item counts
*/
export async function getBoards({ userId, page = 1, limit = 20, includePublic = false }: GetBoardsParams) {
const start = (page - 1) * limit;
const end = start + limit - 1;
let query = supabase
.from('boards')
.select(`
*,
board_items(count)
`)
.eq('user_id', userId)
.order('updated_at', { ascending: false })
.range(start, end);
if (includePublic) {
query = query.or(`user_id.eq.${userId},is_public.eq.true`);
}
const { data, error } = await query;
if (error) throw error;
// Transform the data to include item_count
const boards = data?.map((board: any) => ({
...board,
item_count: board.board_items?.[0]?.count || 0,
board_items: undefined // Remove the nested object
})) as BoardWithCount[];
return boards;
}
/**
* Get a single board by ID
*/
export async function getBoardById(id: string) {
const { data, error } = await supabase
.from('boards')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data as Board;
}
/**
* Create a new board
*/
export async function createBoard(board: BoardInsert) {
const { data, error } = await supabase
.from('boards')
.insert(board)
.select()
.single();
if (error) throw error;
return data as Board;
}
/**
* Update an existing board
*/
export async function updateBoard(id: string, updates: BoardUpdate) {
const { data, error } = await supabase
.from('boards')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data as Board;
}
/**
* Delete a board (cascade deletes all board_items)
*/
export async function deleteBoard(id: string) {
const { error } = await supabase
.from('boards')
.delete()
.eq('id', id);
if (error) throw error;
}
/**
* Duplicate a board with all its items
*/
export async function duplicateBoard(boardId: string, userId: string) {
// Get the original board
const board = await getBoardById(boardId);
// Create new board with same settings
const newBoard = await createBoard({
user_id: userId,
name: `${board.name} (Copy)`,
description: board.description,
canvas_width: board.canvas_width,
canvas_height: board.canvas_height,
background_color: board.background_color,
is_public: false
});
// Get all items from original board
const { data: items, error } = await supabase
.from('board_items')
.select('*')
.eq('board_id', boardId);
if (error) throw error;
// Copy items to new board
if (items && items.length > 0) {
const newItems = items.map(item => ({
board_id: newBoard.id,
image_id: item.image_id,
position_x: item.position_x,
position_y: item.position_y,
scale_x: item.scale_x,
scale_y: item.scale_y,
rotation: item.rotation,
z_index: item.z_index,
opacity: item.opacity,
width: item.width,
height: item.height
}));
const { error: insertError } = await supabase
.from('board_items')
.insert(newItems);
if (insertError) throw insertError;
}
return newBoard;
}
/**
* Generate thumbnail for board (exports to storage)
*/
export async function generateBoardThumbnail(boardId: string, dataUrl: string) {
// Convert data URL to blob
const response = await fetch(dataUrl);
const blob = await response.blob();
// Upload to Supabase Storage
const fileName = `board-thumbnails/${boardId}.png`;
const { data, error } = await supabase.storage
.from('images')
.upload(fileName, blob, {
upsert: true,
contentType: 'image/png'
});
if (error) throw error;
// Get public URL
const { data: urlData } = supabase.storage
.from('images')
.getPublicUrl(fileName);
// Update board with thumbnail URL
await updateBoard(boardId, {
thumbnail_url: urlData.publicUrl
});
return urlData.publicUrl;
}
/**
* Get public boards for explore/sharing
*/
export async function getPublicBoards(page = 1, limit = 20) {
const start = (page - 1) * limit;
const end = start + limit - 1;
const { data, error } = await supabase
.from('boards')
.select(`
*,
board_items(count)
`)
.eq('is_public', true)
.order('updated_at', { ascending: false })
.range(start, end);
if (error) throw error;
const boards = data?.map((board: any) => ({
...board,
item_count: board.board_items?.[0]?.count || 0,
board_items: undefined
})) as BoardWithCount[];
return boards;
}
/**
* Toggle board visibility (public/private)
*/
export async function toggleBoardVisibility(id: string, isPublic: boolean) {
return updateBoard(id, { is_public: isPublic });
}

View file

@ -0,0 +1,73 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
type Image = Database['public']['Tables']['images']['Row'];
export interface GetPublicImagesParams {
page?: number;
limit?: number;
sortBy?: 'recent' | 'popular' | 'trending';
favoritesOnly?: boolean;
}
export async function getPublicImages({
page = 1,
limit = 20,
sortBy = 'recent',
favoritesOnly = false
}: GetPublicImagesParams) {
const start = (page - 1) * limit;
const end = start + limit - 1;
let query = supabase
.from('images')
.select('*')
.eq('is_public', true)
.is('archived_at', null);
// Filter by favorites
if (favoritesOnly) {
query = query.eq('is_favorite', true);
}
query = query.range(start, end);
// Sort by different criteria
if (sortBy === 'recent') {
query = query.order('created_at', { ascending: false });
} else if (sortBy === 'popular') {
query = query.order('download_count', { ascending: false });
} else if (sortBy === 'trending') {
// Combine recency and popularity for trending
query = query.order('created_at', { ascending: false });
}
const { data, error } = await query;
if (error) throw error;
return data as Image[];
}
export async function searchPublicImages(searchTerm: string, page = 1, limit = 20, favoritesOnly = false) {
const start = (page - 1) * limit;
const end = start + limit - 1;
let query = supabase
.from('images')
.select('*')
.eq('is_public', true)
.is('archived_at', null)
.ilike('prompt', `%${searchTerm}%`);
// Filter by favorites
if (favoritesOnly) {
query = query.eq('is_favorite', true);
}
const { data, error } = await query
.order('created_at', { ascending: false })
.range(start, end);
if (error) throw error;
return data as Image[];
}

View file

@ -0,0 +1,250 @@
/**
* Async Image Generation API (New Queue-Based System)
*
* This replaces the old synchronous generate.ts with an async, non-blocking approach.
* Uses the job queue system for better scalability and user experience.
*/
import { supabase } from '$lib/supabase';
import {
startImageGeneration,
subscribeToGeneration,
generateImageWithUpdates,
type GenerateImageJobParams
} from '@picture/shared';
// ============================================================================
// TYPES
// ============================================================================
export interface GenerationProgress {
generationId: string;
status: 'queued' | 'pending' | 'processing' | 'completed' | 'failed';
progress?: number; // 0-100
imageUrl?: string;
error?: string;
}
export type GenerationCallback = (progress: GenerationProgress) => void;
// ============================================================================
// MAIN API FUNCTIONS
// ============================================================================
/**
* Generate an image (async, non-blocking)
*
* Returns immediately with a generation ID.
* Use subscribeToGenerationUpdates() to monitor progress.
*
* @example
* ```typescript
* // Start generation
* const { generationId } = await generateImageAsync({
* prompt: 'A beautiful sunset',
* model_id: 'black-forest-labs/flux-dev'
* });
*
* // Subscribe to updates
* const unsubscribe = subscribeToGenerationUpdates(generationId, (progress) => {
* console.log('Status:', progress.status);
* if (progress.status === 'completed') {
* console.log('Image URL:', progress.imageUrl);
* unsubscribe();
* }
* });
* ```
*/
export async function generateImageAsync(
params: GenerateImageJobParams
): Promise<{ generationId: string; jobId: string }> {
try {
const result = await startImageGeneration(supabase, params);
return result;
} catch (error: any) {
console.error('Failed to start image generation:', error);
throw new Error(error.message || 'Failed to start image generation');
}
}
/**
* Subscribe to generation progress updates via Realtime
*
* @example
* ```typescript
* const unsubscribe = subscribeToGenerationUpdates(generationId, (progress) => {
* console.log(`${progress.status}: ${progress.progress}%`);
*
* if (progress.status === 'completed') {
* displayImage(progress.imageUrl);
* unsubscribe();
* }
* });
* ```
*/
export function subscribeToGenerationUpdates(
generationId: string,
callback: GenerationCallback
): () => void {
return subscribeToGeneration(supabase, generationId, (generation) => {
// Map database status to progress object
const progress: GenerationProgress = {
generationId: generation.id,
status: generation.status,
progress: getProgressPercentage(generation.status),
error: generation.error_message
};
// If completed, fetch the image record
if (generation.status === 'completed') {
fetchGeneratedImage(generationId).then(image => {
if (image) {
progress.imageUrl = image.public_url;
}
callback(progress);
});
} else {
callback(progress);
}
});
}
/**
* All-in-one: Generate image and subscribe to updates
*
* Convenience function that combines generateImageAsync + subscribeToGenerationUpdates.
*
* @example
* ```typescript
* const { generationId, unsubscribe } = await generateWithRealtime(
* { prompt: 'Sunset', model_id: 'flux-dev' },
* (progress) => {
* updateUI(progress);
* if (progress.status === 'completed') {
* showImage(progress.imageUrl);
* unsubscribe();
* }
* }
* );
* ```
*/
export async function generateWithRealtime(
params: GenerateImageJobParams,
onUpdate: GenerationCallback
): Promise<{ generationId: string; jobId: string; unsubscribe: () => void }> {
const result = await generateImageWithUpdates(supabase, params, (generation) => {
const progress: GenerationProgress = {
generationId: generation.id,
status: generation.status,
progress: getProgressPercentage(generation.status),
error: generation.error_message
};
if (generation.status === 'completed') {
fetchGeneratedImage(generation.id).then(image => {
if (image) {
progress.imageUrl = image.public_url;
}
onUpdate(progress);
});
} else {
onUpdate(progress);
}
});
return result;
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Fetch the generated image record
*/
async function fetchGeneratedImage(generationId: string) {
const { data, error } = await supabase
.from('images')
.select('*')
.eq('generation_id', generationId)
.single();
if (error) {
console.error('Failed to fetch generated image:', error);
return null;
}
return data;
}
/**
* Convert status to progress percentage (for UI)
*/
function getProgressPercentage(status: string): number {
switch (status) {
case 'queued':
return 10;
case 'pending':
return 20;
case 'processing':
return 50;
case 'completed':
return 100;
case 'failed':
return 0;
default:
return 0;
}
}
/**
* Get generation status (one-time check, no subscription)
*/
export async function getGenerationStatus(generationId: string): Promise<GenerationProgress | null> {
const { data, error } = await supabase
.from('image_generations')
.select('*')
.eq('id', generationId)
.single();
if (error) {
console.error('Failed to get generation status:', error);
return null;
}
const progress: GenerationProgress = {
generationId: data.id,
status: data.status,
progress: getProgressPercentage(data.status),
error: data.error_message
};
if (data.status === 'completed') {
const image = await fetchGeneratedImage(generationId);
if (image) {
progress.imageUrl = image.public_url;
}
}
return progress;
}
/**
* Cancel a pending generation
*/
export async function cancelGeneration(generationId: string): Promise<void> {
// Update generation status
const { error } = await supabase
.from('image_generations')
.update({ status: 'failed', error_message: 'Cancelled by user' })
.eq('id', generationId)
.eq('status', 'pending'); // Only cancel if still pending
if (error) {
console.error('Failed to cancel generation:', error);
throw new Error('Failed to cancel generation');
}
// Note: The job will still be in queue but will fail when processed
// Could also mark the job as cancelled in job_queue table
}

View file

@ -0,0 +1,113 @@
import { supabase } from '$lib/supabase';
export interface GenerateImageParams {
prompt: string;
model_id: string;
negative_prompt?: string;
width?: number;
height?: number;
num_inference_steps?: number;
guidance_scale?: number;
}
export interface GenerateImageResponse {
image_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
}
export async function generateImage(params: GenerateImageParams): Promise<GenerateImageResponse> {
// Get current user
const {
data: { user },
error: userError
} = await supabase.auth.getUser();
if (userError || !user) {
throw new Error('User not authenticated');
}
// Get model info
const { data: model, error: modelError } = await supabase
.from('models')
.select('*')
.eq('id', params.model_id)
.single();
if (modelError || !model) {
throw new Error('Invalid model selected');
}
// Create generation record first
const { data: generation, error: generationError } = await supabase
.from('image_generations')
.insert({
user_id: user.id,
prompt: params.prompt,
negative_prompt: params.negative_prompt || null,
model: model.name,
width: params.width || model.default_width,
height: params.height || model.default_height,
steps: params.num_inference_steps || model.default_steps,
guidance_scale: params.guidance_scale || model.default_guidance_scale,
status: 'pending'
})
.select()
.single();
if (generationError) {
throw generationError;
}
// Call Edge Function with generation_id
const { data, error } = await supabase.functions.invoke('generate-image', {
body: {
prompt: params.prompt,
negative_prompt: params.negative_prompt,
model_id: model.replicate_id,
model_version: model.version,
width: params.width || model.default_width,
height: params.height || model.default_height,
num_inference_steps: params.num_inference_steps || model.default_steps,
guidance_scale: params.guidance_scale || model.default_guidance_scale,
generation_id: generation.id
}
});
if (error) {
// Log detailed error for debugging
console.error('Edge Function Error:', error);
console.error('Error details:', {
message: error.message,
context: error.context,
details: error
});
// Update generation status to failed
await supabase
.from('image_generations')
.update({
status: 'failed',
error_message: error.message || JSON.stringify(error),
completed_at: new Date().toISOString()
})
.eq('id', generation.id);
throw new Error(error.message || 'Edge Function failed');
}
return {
image_id: generation.id,
status: 'processing'
};
}
export async function checkGenerationStatus(imageId: string) {
const { data, error } = await supabase
.from('images')
.select('*')
.eq('id', imageId)
.single();
if (error) throw error;
return data;
}

View file

@ -0,0 +1,177 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
type Image = Database['public']['Tables']['images']['Row'];
export interface GetImagesParams {
userId: string;
page?: number;
limit?: number;
archived?: boolean;
tagIds?: string[];
favoritesOnly?: boolean;
}
export async function getImages({ userId, page = 1, limit = 20, archived = false, tagIds, favoritesOnly = false }: GetImagesParams) {
const start = (page - 1) * limit;
const end = start + limit - 1;
let query = supabase
.from('images')
.select('*')
.eq('user_id', userId);
// Filter by archived_at: NULL = active, NOT NULL = archived
if (archived) {
query = query.not('archived_at', 'is', null);
} else {
query = query.is('archived_at', null);
}
// Filter by favorites
if (favoritesOnly) {
query = query.eq('is_favorite', true);
}
// Filter by tags if provided
if (tagIds && tagIds.length > 0) {
// Get image IDs that have ALL selected tags
const { data: imageTagsData, error: imageTagsError } = await supabase
.from('image_tags')
.select('image_id')
.in('tag_id', tagIds);
if (imageTagsError) throw imageTagsError;
// Count occurrences of each image_id
const imageIdCounts = imageTagsData?.reduce((acc: Record<string, number>, item) => {
acc[item.image_id] = (acc[item.image_id] || 0) + 1;
return acc;
}, {});
// Filter to only images that have all selected tags
const imageIds = Object.entries(imageIdCounts || {})
.filter(([_, count]) => count === tagIds.length)
.map(([imageId, _]) => imageId);
if (imageIds.length === 0) {
return []; // No images match all tags
}
query = query.in('id', imageIds);
}
const { data, error } = await query
.order('created_at', { ascending: false })
.range(start, end);
if (error) throw error;
return data as Image[];
}
export async function getImageById(id: string) {
const { data, error } = await supabase
.from('images')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data as Image;
}
export async function archiveImage(id: string) {
console.log('[archiveImage] Archiving image:', id);
const { data, error } = await supabase
.from('images')
.update({ archived_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) {
console.error('[archiveImage] Error:', error);
throw error;
}
console.log('[archiveImage] Success:', data);
return data as Image;
}
export async function unarchiveImage(id: string) {
const { data, error } = await supabase
.from('images')
.update({ archived_at: null })
.eq('id', id)
.select()
.single();
if (error) throw error;
return data as Image;
}
export async function deleteImage(id: string) {
const { error } = await supabase
.from('images')
.delete()
.eq('id', id);
if (error) throw error;
}
export async function downloadImage(url: string, filename: string) {
const response = await fetch(url);
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
}
export async function publishImage(id: string) {
const { data, error } = await supabase
.from('images')
.update({ is_public: true })
.eq('id', id)
.select()
.single();
if (error) throw error;
return data as Image;
}
export async function unpublishImage(id: string) {
const { data, error } = await supabase
.from('images')
.update({ is_public: false })
.eq('id', id)
.select()
.single();
if (error) throw error;
return data as Image;
}
export async function toggleFavorite(id: string, isFavorite: boolean) {
console.log('[toggleFavorite] Toggling favorite:', id, 'to', isFavorite);
const { data, error } = await supabase
.from('images')
.update({ is_favorite: isFavorite })
.eq('id', id)
.select()
.single();
if (error) {
console.error('[toggleFavorite] Error:', error);
throw error;
}
console.log('[toggleFavorite] Success:', data);
return data as Image;
}

View file

@ -0,0 +1,22 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
type Model = Database['public']['Tables']['models']['Row'];
export async function getActiveModels() {
const { data, error } = await supabase
.from('models')
.select('*')
.eq('is_active', true)
.order('is_default', { ascending: false });
if (error) throw error;
return data as Model[];
}
export async function getModelById(id: string) {
const { data, error } = await supabase.from('models').select('*').eq('id', id).single();
if (error) throw error;
return data as Model;
}

View file

@ -0,0 +1,85 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
type Tag = Database['public']['Tables']['tags']['Row'];
type TagInsert = Database['public']['Tables']['tags']['Insert'];
export async function getAllTags(): Promise<Tag[]> {
const { data, error } = await supabase
.from('tags')
.select('*')
.order('name', { ascending: true });
if (error) throw error;
return data || [];
}
export async function createTag(tag: Omit<TagInsert, 'id' | 'created_at'>): Promise<Tag> {
const { data, error } = await supabase
.from('tags')
.insert(tag)
.select()
.single();
if (error) throw error;
return data;
}
export async function updateTag(id: string, updates: Partial<TagInsert>): Promise<Tag> {
const { data, error } = await supabase
.from('tags')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
}
export async function deleteTag(id: string): Promise<void> {
const { error } = await supabase
.from('tags')
.delete()
.eq('id', id);
if (error) throw error;
}
export async function getImageTags(imageId: string): Promise<Tag[]> {
const { data, error } = await supabase
.from('image_tags')
.select('tag:tags(*)')
.eq('image_id', imageId);
if (error) throw error;
return data?.map((item: any) => item.tag).filter(Boolean) || [];
}
export async function addTagToImage(imageId: string, tagId: string): Promise<void> {
const { error } = await supabase
.from('image_tags')
.insert({ image_id: imageId, tag_id: tagId });
if (error) throw error;
}
export async function removeTagFromImage(imageId: string, tagId: string): Promise<void> {
const { error } = await supabase
.from('image_tags')
.delete()
.eq('image_id', imageId)
.eq('tag_id', tagId);
if (error) throw error;
}
export async function getImagesByTag(tagId: string) {
const { data, error } = await supabase
.from('image_tags')
.select('image:images(*)')
.eq('tag_id', tagId);
if (error) throw error;
return data?.map((item: any) => item.image).filter(Boolean) || [];
}

View file

@ -0,0 +1,160 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
type Image = Database['public']['Tables']['images']['Row'];
export interface UploadProgress {
filename: string;
progress: number;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
const STORAGE_BUCKET = 'user-uploads';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
export function validateImage(file: File): { valid: boolean; error?: string } {
if (!ALLOWED_TYPES.includes(file.type)) {
return {
valid: false,
error: 'Nur JPG, PNG und WebP Bilder sind erlaubt'
};
}
if (file.size > MAX_FILE_SIZE) {
return {
valid: false,
error: `Datei ist zu groß. Maximale Größe: ${MAX_FILE_SIZE / 1024 / 1024}MB`
};
}
return { valid: true };
}
export async function uploadImage(
file: File,
userId: string,
onProgress?: (progress: number) => void
): Promise<Image> {
// Validate file
const validation = validateImage(file);
if (!validation.valid) {
throw new Error(validation.error);
}
// Generate unique filename
const fileExt = file.name.split('.').pop();
const fileName = `${userId}/${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`;
// Upload to Supabase Storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from(STORAGE_BUCKET)
.upload(fileName, file, {
cacheControl: '3600',
upsert: false
});
if (uploadError) {
console.error('Upload error:', uploadError);
throw new Error('Fehler beim Hochladen des Bildes');
}
// Get public URL
const {
data: { publicUrl }
} = supabase.storage.from(STORAGE_BUCKET).getPublicUrl(fileName);
// Create database entry
const { data: imageData, error: dbError } = await supabase
.from('images')
.insert({
user_id: userId,
public_url: publicUrl,
storage_path: fileName,
filename: file.name,
prompt: `Uploaded: ${file.name}`
})
.select()
.single();
if (dbError) {
// Cleanup: delete uploaded file if DB insert fails
await supabase.storage.from(STORAGE_BUCKET).remove([fileName]);
console.error('Database error:', dbError);
console.error('Error details:', JSON.stringify(dbError, null, 2));
throw new Error(`Fehler beim Speichern des Bildes: ${dbError.message || JSON.stringify(dbError)}`);
}
return imageData as Image;
}
export async function uploadMultipleImages(
files: File[],
userId: string,
onProgressUpdate?: (progress: UploadProgress[]) => void
): Promise<Image[]> {
const progressMap: Map<string, UploadProgress> = new Map();
// Initialize progress for all files
files.forEach((file) => {
progressMap.set(file.name, {
filename: file.name,
progress: 0,
status: 'pending'
});
});
// Update progress callback
const updateProgress = () => {
if (onProgressUpdate) {
onProgressUpdate(Array.from(progressMap.values()));
}
};
updateProgress();
// Upload files sequentially (can be parallelized if needed)
const results: Image[] = [];
for (const file of files) {
const progress = progressMap.get(file.name)!;
progress.status = 'uploading';
updateProgress();
try {
const image = await uploadImage(file, userId, (percent) => {
progress.progress = percent;
updateProgress();
});
progress.status = 'success';
progress.progress = 100;
results.push(image);
} catch (error) {
progress.status = 'error';
progress.error = error instanceof Error ? error.message : 'Upload fehlgeschlagen';
}
updateProgress();
}
return results;
}
export async function deleteUploadedImage(imageId: string, filePath: string): Promise<void> {
// Delete from database
const { error: dbError } = await supabase.from('images').delete().eq('id', imageId);
if (dbError) {
throw new Error('Fehler beim Löschen des Bildes aus der Datenbank');
}
// Delete from storage
const { error: storageError } = await supabase.storage.from(STORAGE_BUCKET).remove([filePath]);
if (storageError) {
console.error('Storage deletion error:', storageError);
// Don't throw here as DB entry is already deleted
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { AppSlider, type AppItem } from '@manacore/shared-ui';
import { actualMode } from '$lib/stores/theme';
import { t } from 'svelte-i18n';
let isDark = $derived($actualMode === 'dark');
let apps = $derived<AppItem[]>([
{
name: 'Picture',
description: $t('app_slider.picture_desc'),
longDescription: $t('app_slider.picture_long_desc'),
icon: '/images/app-icons/picture-logo-gradient.png',
color: '#3b82f6',
comingSoon: false,
status: 'published'
},
{
name: 'Memoro',
description: $t('app_slider.memoro_desc'),
longDescription: $t('app_slider.memoro_long_desc'),
icon: '/images/app-icons/memoro-logo-gradient.png',
color: '#f8d62b',
comingSoon: false,
status: 'published'
},
{
name: 'Märchenzauber',
description: $t('app_slider.maerchenzauber_desc'),
longDescription: $t('app_slider.maerchenzauber_long_desc'),
icon: '/images/app-icons/maerchenzauber-logo-gradient.png',
color: '#FF6B9D',
comingSoon: true,
status: 'beta'
},
{
name: 'ManaDeck',
description: $t('app_slider.manadeck_desc'),
longDescription: $t('app_slider.manadeck_long_desc'),
icon: '/images/app-icons/manadeck-logo-gradient.png',
color: '#8b5cf6',
comingSoon: true,
status: 'development'
},
{
name: 'Moodlit',
description: $t('app_slider.moodlit_desc'),
longDescription: $t('app_slider.moodlit_long_desc'),
icon: '/images/app-icons/moodlit-logo-gradient.png',
color: '#9C27B0',
comingSoon: true,
status: 'planning'
},
{
name: 'Manacore',
description: $t('app_slider.manacore_desc'),
longDescription: $t('app_slider.manacore_long_desc'),
icon: '/images/app-icons/manacore-logo-gradient.png',
color: '#00BCD4',
comingSoon: true,
status: 'development'
}
]);
let statusLabels = $derived({
published: $t('app_slider.status_published'),
beta: $t('app_slider.status_beta'),
development: $t('app_slider.status_development'),
planning: $t('app_slider.status_planning')
});
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title={$t('app_slider.title')}
{isDark}
{statusLabels}
comingSoonLabel={$t('app_slider.coming_soon')}
openAppLabel={$t('app_slider.download')}
onAppClick={handleAppClick}
/>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { LanguageSelector } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import { actualMode } from '$lib/stores/theme';
let isDark = $derived($actualMode === 'dark');
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
</script>
<LanguageSelector
{currentLocale}
{supportedLocales}
onLocaleChange={handleLocaleChange}
{isDark}
primaryColor="#3b82f6"
/>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import type { Database } from '@picture/shared/types';
import { showContextMenu } from '$lib/stores/contextMenu';
type Image = Database['public']['Tables']['images']['Row'];
interface Props {
image: Image;
onclick: () => void;
}
let { image, onclick }: Props = $props();
let imageLoaded = $state(false);
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, image);
}
function handleImageLoad() {
imageLoaded = true;
}
function formatDate(dateString: string) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric'
}).format(date);
}
</script>
<button
{onclick}
oncontextmenu={handleContextMenu}
class="group relative aspect-square overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
type="button"
>
<img
src={image.public_url}
alt={image.prompt}
class="h-full w-full object-cover transition-opacity duration-300 {imageLoaded ? 'opacity-100' : 'opacity-15'}"
loading="lazy"
onload={handleImageLoad}
/>
<!-- Overlay on hover -->
<div
class="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 opacity-0 transition-opacity group-hover:opacity-100"
>
<p class="text-base text-white font-medium">{image.prompt}</p>
<p class="mt-1 text-sm text-gray-300">{formatDate(image.created_at)}</p>
</div>
<!-- Archived badge - always visible -->
<div class="absolute right-2 top-2 rounded-full bg-gray-800/90 px-2 py-1">
<span class="text-xs font-medium text-white">Archived</span>
</div>
</button>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import type { Database } from '@picture/shared/types';
import Modal from '../ui/Modal.svelte';
import Button from '../ui/Button.svelte';
import { unarchiveImage, deleteImage, downloadImage } from '$lib/api/images';
import { archivedImages } from '$lib/stores/archive';
type Image = Database['public']['Tables']['images']['Row'];
interface Props {
image: Image | null;
onClose: () => void;
}
let { image, onClose }: Props = $props();
let isUnarchiving = $state(false);
let isDeleting = $state(false);
async function handleUnarchive() {
if (!image) return;
isUnarchiving = true;
try {
await unarchiveImage(image.id);
// Update store
archivedImages.update((current) => current.filter((img) => img.id !== image.id));
onClose();
} catch (error) {
console.error('Error unarchiving image:', error);
alert('Failed to unarchive image');
} finally {
isUnarchiving = false;
}
}
async function handleDelete() {
if (!image) return;
if (!confirm('Are you sure you want to delete this image? This action cannot be undone.'))
return;
isDeleting = true;
try {
await deleteImage(image.id);
// Update store
archivedImages.update((current) => current.filter((img) => img.id !== image.id));
onClose();
} catch (error) {
console.error('Error deleting image:', error);
alert('Failed to delete image');
} finally {
isDeleting = false;
}
}
function handleDownload() {
if (!image) return;
const filename = `picture-${image.id}.png`;
downloadImage(image.public_url, filename);
}
function formatDate(dateString: string) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
</script>
<Modal open={!!image} {onClose}>
{#if image}
<div class="flex flex-col gap-6 p-6 md:flex-row">
<!-- Image -->
<div class="flex-1">
<img
src={image.public_url}
alt={image.prompt}
class="h-auto w-full rounded-lg object-contain"
/>
</div>
<!-- Details -->
<div class="w-full space-y-6 md:w-96">
<div>
<h2 class="text-2xl font-bold text-gray-900">Details</h2>
<div class="mt-2 inline-block rounded-full bg-gray-100 px-3 py-1">
<span class="text-sm font-medium text-gray-700">Archived</span>
</div>
</div>
<!-- Prompt -->
<div>
<h3 class="mb-2 text-sm font-medium text-gray-500">Prompt</h3>
<p class="text-gray-900">{image.prompt}</p>
</div>
<!-- Model -->
<div>
<h3 class="mb-2 text-sm font-medium text-gray-500">Model</h3>
<p class="text-gray-900">{image.model_id || 'Unknown'}</p>
</div>
<!-- Created At -->
<div>
<h3 class="mb-2 text-sm font-medium text-gray-500">Created</h3>
<p class="text-gray-900">{formatDate(image.created_at)}</p>
</div>
<!-- Actions -->
<div class="space-y-2">
<Button variant="primary" class="w-full" onclick={handleDownload}>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download
</Button>
<Button
variant="secondary"
class="w-full"
onclick={handleUnarchive}
loading={isUnarchiving}
disabled={isUnarchiving || isDeleting}
>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
Restore to Gallery
</Button>
<Button
variant="danger"
class="w-full"
onclick={handleDelete}
loading={isDeleting}
disabled={isUnarchiving || isDeleting}
>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Delete Permanently
</Button>
</div>
</div>
</div>
{/if}
</Modal>

View file

@ -0,0 +1,136 @@
<script lang="ts">
import Button from '../ui/Button.svelte';
import Input from '../ui/Input.svelte';
import Card from '../ui/Card.svelte';
import { supabase } from '$lib/supabase';
import { goto } from '$app/navigation';
let email = $state('');
let password = $state('');
let loading = $state(false);
let error = $state('');
async function handleLogin() {
if (!email || !password) {
error = 'Please fill in all fields';
return;
}
loading = true;
error = '';
const { data, error: authError } = await supabase.auth.signInWithPassword({
email,
password
});
loading = false;
if (authError) {
error = authError.message;
return;
}
if (data.session) {
goto('/app/gallery');
}
}
async function handleGoogleLogin() {
loading = true;
const { error: authError } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/app/gallery`
}
});
if (authError) {
error = authError.message;
loading = false;
}
}
</script>
<Card class="w-full max-w-md">
<div class="mb-6 text-center">
<h2 class="text-2xl font-bold text-gray-900">Welcome back</h2>
<p class="mt-2 text-sm text-gray-600">Sign in to your account</p>
</div>
{#if error}
<div class="mb-4 rounded-md bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
<div>
<Input
type="email"
label="Email"
placeholder="you@example.com"
bind:value={email}
required
autocomplete="email"
/>
</div>
<div>
<Input
type="password"
label="Password"
placeholder="••••••••"
bind:value={password}
required
autocomplete="current-password"
/>
</div>
<div class="flex items-center justify-between">
<a href="/auth/forgot-password" class="text-sm text-blue-600 hover:text-blue-500">
Forgot password?
</a>
</div>
<Button type="submit" variant="primary" class="w-full" {loading}>
Sign in
</Button>
</form>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white px-2 text-gray-500">Or continue with</span>
</div>
</div>
<Button variant="outline" class="w-full" onclick={handleGoogleLogin} {loading}>
<svg class="mr-2 h-5 w-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</Button>
<p class="mt-6 text-center text-sm text-gray-600">
Don't have an account?
<a href="/auth/signup" class="font-medium text-blue-600 hover:text-blue-500"> Sign up </a>
</p>
</Card>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import Button from '../ui/Button.svelte';
import Input from '../ui/Input.svelte';
import Card from '../ui/Card.svelte';
import { supabase } from '$lib/supabase';
import { goto } from '$app/navigation';
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let loading = $state(false);
let error = $state('');
let success = $state(false);
async function handleSignup() {
if (!email || !password || !confirmPassword) {
error = 'Please fill in all fields';
return;
}
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 6) {
error = 'Password must be at least 6 characters';
return;
}
loading = true;
error = '';
const { data, error: authError } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/app/gallery`
}
});
loading = false;
if (authError) {
error = authError.message;
return;
}
// Check if email confirmation is required
if (data.user && !data.session) {
success = true;
} else if (data.session) {
goto('/app/gallery');
}
}
</script>
<Card class="w-full max-w-md">
<div class="mb-6 text-center">
<h2 class="text-2xl font-bold text-gray-900">Create account</h2>
<p class="mt-2 text-sm text-gray-600">Start generating AI images today</p>
</div>
{#if success}
<div class="mb-4 rounded-md bg-green-50 p-4">
<h3 class="text-sm font-medium text-green-800">Check your email</h3>
<p class="mt-2 text-sm text-green-700">
We've sent you a confirmation link. Please check your email to verify your account.
</p>
</div>
{:else}
{#if error}
<div class="mb-4 rounded-md bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSignup(); }} class="space-y-4">
<div>
<Input
type="email"
label="Email"
placeholder="you@example.com"
bind:value={email}
required
autocomplete="email"
/>
</div>
<div>
<Input
type="password"
label="Password"
placeholder="••••••••"
bind:value={password}
required
autocomplete="new-password"
/>
</div>
<div>
<Input
type="password"
label="Confirm Password"
placeholder="••••••••"
bind:value={confirmPassword}
required
autocomplete="new-password"
/>
</div>
<Button type="submit" variant="primary" class="w-full" {loading}>
Create account
</Button>
</form>
<p class="mt-6 text-center text-sm text-gray-600">
Already have an account?
<a href="/auth/login" class="font-medium text-blue-600 hover:text-blue-500"> Sign in </a>
</p>
{/if}
</Card>

View file

@ -0,0 +1,644 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import { page } from '$app/stores';
import { boardSettings } from '$lib/stores/boards';
import {
canvasItems,
selectedItemIds,
canvasZoom,
canvasPan,
canvasMode,
showGrid,
snapToGrid,
gridSize,
selectItem,
deselectAll,
updateCanvasItem,
removeSelectedItems,
undo,
redo,
canUndo,
canRedo
} from '$lib/stores/canvas';
import { updateBoardItem, updateBoardItems, isImageItem, isTextItem } from '$lib/api/boardItems';
import { editingTextId, startEditingText, stopEditingText } from '$lib/stores/canvas';
let container: HTMLDivElement;
let stage: Konva.Stage;
let layer: Konva.Layer;
let backgroundLayer: Konva.Layer;
let transformer: Konva.Transformer;
let gridLayer: Konva.Layer;
// Track nodes for updates
let imageNodes = new Map<string, Konva.Image>();
let textNodes = new Map<string, Konva.Text>();
let isPanning = $state(false);
let isSaving = $state(false);
// Text editing
let textEditingOverlay: HTMLTextAreaElement | null = null;
const boardId = $derived($page.params.id);
onMount(() => {
initializeCanvas();
setupKeyboardShortcuts();
renderItems();
// Subscribe to canvas items changes
const unsubscribe = canvasItems.subscribe(() => {
if (stage) {
renderItems();
}
});
return () => {
unsubscribe();
};
});
onDestroy(() => {
if (stage) {
stage.destroy();
}
});
function initializeCanvas() {
const width = container.clientWidth;
const height = container.clientHeight;
// Create stage
stage = new Konva.Stage({
container: container,
width: width,
height: height,
draggable: false
});
// Background layer for grid
backgroundLayer = new Konva.Layer();
stage.add(backgroundLayer);
// Grid layer
gridLayer = new Konva.Layer();
stage.add(gridLayer);
drawGrid();
// Main layer for images
layer = new Konva.Layer();
stage.add(layer);
// Transformer for resize/rotate
transformer = new Konva.Transformer({
nodes: [],
// All 8 anchors for maximum control
enabledAnchors: [
'top-left', 'top-center', 'top-right',
'middle-left', 'middle-right',
'bottom-left', 'bottom-center', 'bottom-right'
],
rotateEnabled: true,
rotateAnchorOffset: 60, // Distance of rotation handle from image
rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315], // Snap angles when Shift is pressed
// Visual styling
borderStroke: '#4A90E2',
borderStrokeWidth: 2,
borderDash: [4, 4], // Dashed border
// Anchor (handle) styling
anchorFill: '#4A90E2',
anchorStroke: '#fff',
anchorStrokeWidth: 2,
anchorSize: 10,
anchorCornerRadius: 2,
// Rotation anchor styling
rotateAnchorCursor: 'grab',
// Behavior
keepRatio: false, // Shift+Drag for aspect ratio lock
centeredScaling: false, // Alt+Drag for centered scaling
flipEnabled: false, // Prevent accidental flipping
// Boundaries
boundBoxFunc: (oldBox, newBox) => {
// Prevent too small sizes
if (newBox.width < 20 || newBox.height < 20) {
return oldBox;
}
// Prevent too large sizes (optional)
if (newBox.width > 5000 || newBox.height > 5000) {
return oldBox;
}
return newBox;
}
});
layer.add(transformer);
// Stage click to deselect
stage.on('click', (e) => {
console.log('[Canvas] Stage clicked, target:', e.target.getType());
if (e.target === stage || e.target === backgroundLayer) {
console.log('[Canvas] Deselecting all');
deselectAll();
transformer.nodes([]);
layer.batchDraw();
}
});
// Mouse wheel zoom
stage.on('wheel', (e) => {
e.evt.preventDefault();
handleZoom(e);
});
// Handle window resize
window.addEventListener('resize', handleResize);
}
function drawGrid() {
if (!gridLayer || !$showGrid) return;
gridLayer.destroyChildren();
const size = $gridSize;
const width = $boardSettings.width;
const height = $boardSettings.height;
// Vertical lines
for (let x = 0; x <= width; x += size) {
const line = new Konva.Line({
points: [x, 0, x, height],
stroke: '#e5e7eb',
strokeWidth: 1,
listening: false
});
gridLayer.add(line);
}
// Horizontal lines
for (let y = 0; y <= height; y += size) {
const line = new Konva.Line({
points: [0, y, width, y],
stroke: '#e5e7eb',
strokeWidth: 1,
listening: false
});
gridLayer.add(line);
}
gridLayer.batchDraw();
}
function renderItems() {
if (!layer) return;
console.log('[Canvas] Rendering items:', $canvasItems.length);
// Remove existing nodes, keep transformer
imageNodes.forEach(node => node.destroy());
textNodes.forEach(node => node.destroy());
imageNodes.clear();
textNodes.clear();
// Render each item based on type
$canvasItems.forEach(item => {
if (isImageItem(item)) {
renderImageItem(item);
} else if (isTextItem(item)) {
renderTextItem(item);
}
});
}
function renderImageItem(item: any) {
const imageObj = new Image();
imageObj.crossOrigin = 'Anonymous';
imageObj.onload = () => {
const konvaImage = new Konva.Image({
id: item.id,
name: item.id,
x: item.position_x,
y: item.position_y,
image: imageObj,
width: item.width || imageObj.width,
height: item.height || imageObj.height,
scaleX: item.scale_x,
scaleY: item.scale_y,
rotation: item.rotation,
draggable: true,
opacity: item.opacity
});
// Click to select
konvaImage.on('click tap', (e) => {
e.cancelBubble = true;
const multiSelect = e.evt.shiftKey || e.evt.metaKey || e.evt.ctrlKey;
selectItem(item.id, multiSelect);
setTimeout(() => updateTransformer(), 0);
});
// Drag events
konvaImage.on('dragstart', () => {
selectItem(item.id, false);
setTimeout(() => updateTransformer(), 0);
});
konvaImage.on('dragend', () => handleDragEnd(konvaImage, item.id));
konvaImage.on('transformend', () => handleTransformEnd(konvaImage, item.id));
layer.add(konvaImage);
imageNodes.set(item.id, konvaImage);
// Update transformer if selected
if ($selectedItemIds.includes(item.id)) {
setTimeout(() => updateTransformer(), 0);
}
layer.batchDraw();
};
imageObj.onerror = (error) => console.error('[Canvas] Failed to load image:', item.id, error);
imageObj.src = item.image.public_url;
}
function renderTextItem(item: any) {
const konvaText = new Konva.Text({
id: item.id,
name: item.id,
x: item.position_x,
y: item.position_y,
text: item.text_content,
fontSize: item.font_size,
fontFamily: item.properties?.fontFamily || 'Arial',
fontStyle: `${item.properties?.fontStyle || 'normal'} ${item.properties?.fontWeight || 'normal'}`,
fill: item.color,
width: item.width || 300,
align: item.properties?.textAlign || 'left',
rotation: item.rotation,
scaleX: item.scale_x,
scaleY: item.scale_y,
opacity: item.opacity,
draggable: true,
lineHeight: item.properties?.lineHeight || 1.2
});
// Click to select
konvaText.on('click tap', (e) => {
e.cancelBubble = true;
const multiSelect = e.evt.shiftKey || e.evt.metaKey || e.evt.ctrlKey;
selectItem(item.id, multiSelect);
setTimeout(() => updateTransformer(), 0);
});
// Double-click to edit
konvaText.on('dblclick dbltap', () => {
showTextEditOverlay(konvaText, item);
});
// Drag events
konvaText.on('dragstart', () => {
selectItem(item.id, false);
setTimeout(() => updateTransformer(), 0);
});
konvaText.on('dragend', () => handleDragEnd(konvaText, item.id));
konvaText.on('transformend', () => handleTextTransformEnd(konvaText, item.id));
layer.add(konvaText);
textNodes.set(item.id, konvaText);
// Update transformer if selected
if ($selectedItemIds.includes(item.id)) {
setTimeout(() => updateTransformer(), 0);
}
layer.batchDraw();
}
function updateTransformer() {
if (!transformer || !layer) return;
// Collect all selected nodes (images + texts)
const selectedNodes: (Konva.Image | Konva.Text)[] = [];
$selectedItemIds.forEach(id => {
const imageNode = imageNodes.get(id);
const textNode = textNodes.get(id);
if (imageNode) selectedNodes.push(imageNode);
if (textNode) selectedNodes.push(textNode);
});
transformer.nodes(selectedNodes);
if (selectedNodes.length > 0) {
transformer.show();
transformer.visible(true);
transformer.moveToTop();
} else {
transformer.hide();
}
layer.batchDraw();
}
async function handleDragEnd(node: Konva.Image | Konva.Text, itemId: string) {
let x = node.x();
let y = node.y();
// Snap to grid if enabled
if ($snapToGrid) {
const size = $gridSize;
x = Math.round(x / size) * size;
y = Math.round(y / size) * size;
node.x(x);
node.y(y);
}
// Update store
updateCanvasItem(itemId, {
position_x: x,
position_y: y
});
// Save to database
await saveBoardItem(itemId, { position_x: x, position_y: y });
}
async function handleTransformEnd(node: Konva.Image, itemId: string) {
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const rotation = node.rotation();
// Update store
updateCanvasItem(itemId, {
scale_x: scaleX,
scale_y: scaleY,
rotation: rotation
});
// Save to database
await saveBoardItem(itemId, {
scale_x: scaleX,
scale_y: scaleY,
rotation: rotation
});
}
async function handleTextTransformEnd(node: Konva.Text, itemId: string) {
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const rotation = node.rotation();
const width = node.width() * scaleX;
// Reset scale and apply to width for text
node.scaleX(1);
node.width(width);
// Update store
updateCanvasItem(itemId, {
width: Math.round(width),
scale_x: 1,
scale_y: scaleY,
rotation: rotation
});
// Save to database
await saveBoardItem(itemId, {
width: Math.round(width),
scale_x: 1,
scale_y: scaleY,
rotation: rotation
});
}
function showTextEditOverlay(textNode: Konva.Text, item: any) {
// Hide text node while editing
textNode.hide();
layer.batchDraw();
// Get absolute position
const textPosition = textNode.getAbsolutePosition();
const stageBox = stage.container().getBoundingClientRect();
// Create textarea
const textarea = document.createElement('textarea');
textarea.value = textNode.text();
textarea.style.position = 'absolute';
textarea.style.top = `${stageBox.top + textPosition.y}px`;
textarea.style.left = `${stageBox.left + textPosition.x}px`;
textarea.style.width = `${textNode.width() * textNode.scaleX()}px`;
textarea.style.fontSize = `${textNode.fontSize()}px`;
textarea.style.fontFamily = textNode.fontFamily();
textarea.style.color = textNode.fill();
textarea.style.border = '2px solid #4A90E2';
textarea.style.padding = '4px';
textarea.style.margin = '0px';
textarea.style.overflow = 'hidden';
textarea.style.background = 'white';
textarea.style.outline = 'none';
textarea.style.resize = 'none';
textarea.style.lineHeight = String(textNode.lineHeight());
textarea.style.transformOrigin = 'left top';
textarea.style.textAlign = textNode.align();
textarea.style.zIndex = '1000';
const saveText = async () => {
const newText = textarea.value;
textNode.text(newText);
document.body.removeChild(textarea);
textEditingOverlay = null;
textNode.show();
layer.batchDraw();
// Update store and database
updateCanvasItem(item.id, { text_content: newText });
await saveBoardItem(item.id, { text_content: newText });
stopEditingText();
};
const cancelEdit = () => {
document.body.removeChild(textarea);
textEditingOverlay = null;
textNode.show();
layer.batchDraw();
stopEditingText();
};
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveText();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
});
textarea.addEventListener('blur', () => {
saveText();
});
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textEditingOverlay = textarea;
startEditingText(item.id);
}
async function saveBoardItem(itemId: string, updates: any) {
if (isSaving) return;
try {
isSaving = true;
await updateBoardItem(itemId, updates);
} catch (error) {
console.error('Error saving board item:', error);
} finally {
isSaving = false;
}
}
function handleZoom(e: Konva.KonvaEventObject<WheelEvent>) {
const oldScale = stage.scaleX();
const pointer = stage.getPointerPosition();
if (!pointer) return;
const direction = e.evt.deltaY > 0 ? -1 : 1;
const newScale = direction > 0 ? oldScale * 1.1 : oldScale / 1.1;
const clampedScale = Math.max(0.1, Math.min(5, newScale));
canvasZoom.set(clampedScale);
// Zoom to pointer position
const mousePointTo = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale
};
const newPos = {
x: pointer.x - mousePointTo.x * clampedScale,
y: pointer.y - mousePointTo.y * clampedScale
};
stage.scale({ x: clampedScale, y: clampedScale });
stage.position(newPos);
canvasPan.set(newPos);
}
function setupKeyboardShortcuts() {
function handleKeyDown(e: KeyboardEvent) {
// Ignore if typing in input
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
switch (e.key.toLowerCase()) {
case 'delete':
case 'backspace':
e.preventDefault();
removeSelectedItems();
transformer.nodes([]);
break;
case 'escape':
deselectAll();
transformer.nodes([]);
break;
case 'z':
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
if (e.shiftKey && $canRedo) {
redo();
} else if ($canUndo) {
undo();
}
}
break;
case 'a':
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
// Select all
selectedItemIds.set($canvasItems.map(item => item.id));
updateTransformer();
}
break;
case ' ':
e.preventDefault();
isPanning = true;
stage.draggable(true);
container.style.cursor = 'grab';
break;
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.key === ' ') {
isPanning = false;
stage.draggable(false);
container.style.cursor = 'default';
}
}
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}
function handleResize() {
if (!stage || !container) return;
const width = container.clientWidth;
const height = container.clientHeight;
stage.width(width);
stage.height(height);
stage.batchDraw();
}
// React to zoom/pan store changes
$effect(() => {
if (stage) {
stage.scale({ x: $canvasZoom, y: $canvasZoom });
stage.position($canvasPan);
}
});
// React to grid changes
$effect(() => {
if ($showGrid) {
drawGrid();
gridLayer.show();
} else {
gridLayer.hide();
}
});
// React to selected items changes
$effect(() => {
console.log('[Canvas] Selected items changed:', $selectedItemIds);
if (transformer && layer) {
updateTransformer();
}
});
</script>
<div
bind:this={container}
class="h-full w-full"
style="background-color: {$boardSettings.backgroundColor}"
></div>
<style>
:global(.konvajs-content) {
width: 100% !important;
height: 100% !important;
}
</style>

View file

@ -0,0 +1,282 @@
<script lang="ts">
import { page } from '$app/stores';
import {
canvasZoom,
showGrid,
snapToGrid,
zoomIn,
zoomOut,
resetZoom,
zoomToFit,
undo,
redo,
canUndo,
canRedo,
selectedItemIds,
removeSelectedItems,
deselectAll
} from '$lib/stores/canvas';
import { boardSettings } from '$lib/stores/boards';
import Button from '$lib/components/ui/Button.svelte';
import Konva from 'konva';
interface Props {
boardName: string;
onBack: () => void;
onAddImages: () => void;
onAddText: () => void;
}
let { boardName, onBack, onAddImages, onAddText }: Props = $props();
const zoomPercentage = $derived(Math.round($canvasZoom * 100));
const hasSelection = $derived($selectedItemIds.length > 0);
async function handleExport() {
// Get the stage element
const stage = document.querySelector('.konvajs-content canvas') as HTMLCanvasElement;
if (!stage) return;
// Create download link
const dataURL = stage.toDataURL({
pixelRatio: 2, // Retina quality
mimeType: 'image/png'
});
const link = document.createElement('a');
link.download = `${boardName}.png`;
link.href = dataURL;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function handleZoomToFit() {
const container = document.querySelector('.konvajs-content')?.parentElement;
if (!container) return;
zoomToFit(
container.clientWidth,
container.clientHeight,
$boardSettings.width,
$boardSettings.height
);
}
function handleDelete() {
if (!hasSelection) return;
removeSelectedItems();
}
</script>
<div
class="fixed left-0 right-0 top-0 z-50 border-b border-gray-200 bg-white/95 backdrop-blur-xl dark:border-gray-700 dark:bg-gray-900/95"
>
<div class="flex h-16 items-center justify-between px-4">
<!-- Left: Back + Board Name -->
<div class="flex items-center gap-4">
<button
onclick={onBack}
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
title="Zurück"
>
<svg class="h-5 w-5" 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>
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{boardName}</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">
{$boardSettings.width} × {$boardSettings.height}px
</p>
</div>
</div>
<!-- Center: Tools -->
<div class="flex items-center gap-2">
<!-- Undo/Redo -->
<button
onclick={undo}
disabled={!$canUndo}
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 disabled:opacity-30 dark:text-gray-400 dark:hover:bg-gray-800"
title="Rückgängig (⌘Z)"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
</button>
<button
onclick={redo}
disabled={!$canRedo}
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 disabled:opacity-30 dark:text-gray-400 dark:hover:bg-gray-800"
title="Wiederherstellen (⌘⇧Z)"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 10h-10a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6"
/>
</svg>
</button>
<div class="mx-2 h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- Zoom Controls -->
<button
onclick={zoomOut}
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
title="Verkleinern"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
</svg>
</button>
<div class="flex h-10 min-w-[4rem] items-center justify-center rounded-lg bg-gray-100 px-3 text-sm font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100">
{zoomPercentage}%
</div>
<button
onclick={zoomIn}
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
title="Vergrößern"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<button
onclick={handleZoomToFit}
class="flex h-10 px-3 items-center justify-center rounded-lg text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
title="An Fenster anpassen"
>
Fit
</button>
<button
onclick={resetZoom}
class="flex h-10 px-3 items-center justify-center rounded-lg text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
title="100%"
>
100%
</button>
<div class="mx-2 h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- Grid Toggle -->
<button
onclick={() => showGrid.set(!$showGrid)}
class="flex h-10 w-10 items-center justify-center rounded-lg transition-colors {$showGrid
? 'bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
title="Raster {$showGrid ? 'ausblenden' : 'einblenden'}"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 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"
/>
</svg>
</button>
<!-- Snap Toggle -->
<button
onclick={() => snapToGrid.set(!$snapToGrid)}
class="flex h-10 w-10 items-center justify-center rounded-lg transition-colors {$snapToGrid
? 'bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
title="Am Raster einrasten"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</button>
{#if hasSelection}
<div class="mx-2 h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- Delete Selection -->
<button
onclick={handleDelete}
class="flex h-10 w-10 items-center justify-center rounded-lg text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
title="Löschen (Entf)"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
<!-- Right: Actions -->
<div class="flex items-center gap-2">
<Button variant="outline" onclick={onAddText}>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
Text
</Button>
<Button variant="outline" onclick={onAddImages}>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Bilder
</Button>
<Button variant="secondary" onclick={handleExport}>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Exportieren
</Button>
</div>
</div>
</div>

View file

@ -0,0 +1,290 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { user } from '$lib/stores/auth';
import { images, isLoading as isLoadingImages } from '$lib/stores/images';
import { canvasItems, addCanvasItem } from '$lib/stores/canvas';
import { getImages } from '$lib/api/images';
import { addBoardItem, isImageOnBoard } from '$lib/api/boardItems';
import { showToast } from '$lib/stores/toast';
import Modal from '$lib/components/ui/Modal.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { Database } from '@picture/shared/types';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
type Image = Database['public']['Tables']['images']['Row'];
let selectedImages = $state<Set<string>>(new Set());
let isAdding = $state(false);
let searchQuery = $state('');
let currentPage = $state(1);
let hasMore = $state(true);
const boardId = $derived($page.params.id);
// Load images when modal opens
$effect(() => {
if (open && $user) {
loadImages();
}
});
async function loadImages() {
if (!$user) return;
isLoadingImages.set(true);
try {
const data = await getImages({
userId: $user.id,
page: 1,
limit: 50,
archived: false
});
images.set(data);
currentPage = 1;
hasMore = data.length === 50;
} catch (error) {
console.error('Error loading images:', error);
showToast('Fehler beim Laden der Bilder', 'error');
} finally {
isLoadingImages.set(false);
}
}
function toggleImageSelection(imageId: string) {
if (selectedImages.has(imageId)) {
selectedImages.delete(imageId);
} else {
selectedImages.add(imageId);
}
// Trigger reactivity
selectedImages = new Set(selectedImages);
}
function isImageSelected(imageId: string): boolean {
return selectedImages.has(imageId);
}
function isImageAlreadyOnBoard(imageId: string): boolean {
return $canvasItems.some(item => item.image_id === imageId);
}
async function handleAddImages() {
if (selectedImages.size === 0 || !boardId) return;
isAdding = true;
try {
let addedCount = 0;
for (const imageId of selectedImages) {
// Check if already on board
if (isImageAlreadyOnBoard(imageId)) {
continue;
}
// Get image details
const image = $images.find(img => img.id === imageId);
if (!image) continue;
// Add to board
const boardItem = await addBoardItem({
board_id: boardId,
image_id: imageId,
position_x: 100 + (addedCount * 20), // Offset each image slightly
position_y: 100 + (addedCount * 20),
scale_x: 1,
scale_y: 1,
rotation: 0,
z_index: addedCount,
opacity: 1,
width: image.width || 400,
height: image.height || 300
});
// Add to canvas
addCanvasItem(boardItem);
addedCount++;
}
selectedImages.clear();
selectedImages = new Set();
showToast(`${addedCount} ${addedCount === 1 ? 'Bild' : 'Bilder'} hinzugefügt`, 'success');
onClose();
} catch (error) {
console.error('Error adding images:', error);
showToast('Fehler beim Hinzufügen', 'error');
} finally {
isAdding = false;
}
}
function handleSelectAll() {
const availableImages = $images.filter(img => !isImageAlreadyOnBoard(img.id));
selectedImages = new Set(availableImages.map(img => img.id));
}
function handleDeselectAll() {
selectedImages.clear();
selectedImages = new Set();
}
const filteredImages = $derived(
searchQuery.trim()
? $images.filter(img =>
img.prompt?.toLowerCase().includes(searchQuery.toLowerCase())
)
: $images
);
</script>
<Modal {open} onClose={onClose} size="large">
<div class="flex h-[80vh] flex-col p-6">
<!-- Header -->
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Bilder hinzufügen</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Wähle Bilder aus deiner Galerie aus
</p>
</div>
<!-- Search & Actions -->
<div class="mb-4 flex items-center gap-3">
<div class="relative flex-1">
<input
type="text"
bind:value={searchQuery}
placeholder="Bilder suchen..."
class="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 pl-10 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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"
/>
</svg>
</div>
<Button
variant="outline"
size="sm"
onclick={selectedImages.size > 0 ? handleDeselectAll : handleSelectAll}
>
{selectedImages.size > 0 ? 'Abwählen' : 'Alle auswählen'}
</Button>
</div>
<!-- Selection Info -->
{#if selectedImages.size > 0}
<div class="mb-4 rounded-lg bg-blue-50 p-3 dark:bg-blue-950">
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
{selectedImages.size} {selectedImages.size === 1 ? 'Bild' : 'Bilder'} ausgewählt
</p>
</div>
{/if}
<!-- Images Grid -->
<div class="flex-1 overflow-y-auto">
{#if $isLoadingImages}
<div class="grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5">
{#each Array(15) as _}
<div class="aspect-square animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700"></div>
{/each}
</div>
{:else if filteredImages.length === 0}
<div class="flex h-full flex-col items-center justify-center py-12">
<svg
class="h-16 w-16 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p class="mt-4 text-gray-600 dark:text-gray-400">
{searchQuery ? 'Keine Bilder gefunden' : 'Keine Bilder in deiner Galerie'}
</p>
</div>
{:else}
<div class="grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5">
{#each filteredImages as image (image.id)}
{@const selected = isImageSelected(image.id)}
{@const alreadyOnBoard = isImageAlreadyOnBoard(image.id)}
<button
onclick={() => !alreadyOnBoard && toggleImageSelection(image.id)}
disabled={alreadyOnBoard}
class="group relative aspect-square overflow-hidden rounded-lg transition-all {selected
? 'ring-4 ring-blue-500 ring-offset-2 dark:ring-offset-gray-800'
: ''} {alreadyOnBoard ? 'opacity-40 cursor-not-allowed' : 'hover:ring-2 hover:ring-gray-300'}"
>
<img
src={image.public_url}
alt={image.prompt || 'Image'}
class="h-full w-full object-cover"
/>
<!-- Selection Indicator -->
{#if selected}
<div
class="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 text-white"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
{/if}
<!-- Already on Board Badge -->
{#if alreadyOnBoard}
<div
class="absolute inset-0 flex items-center justify-center bg-black/60 text-xs font-medium text-white"
>
Bereits auf Board
</div>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Footer Actions -->
<div class="mt-6 flex gap-3">
<Button variant="outline" class="flex-1" onclick={onClose}>
Abbrechen
</Button>
<Button
class="flex-1"
onclick={handleAddImages}
disabled={selectedImages.size === 0}
loading={isAdding}
>
{selectedImages.size > 0
? `${selectedImages.size} ${selectedImages.size === 1 ? 'Bild' : 'Bilder'} hinzufügen`
: 'Bilder hinzufügen'}
</Button>
</div>
</div>
</Modal>

View file

@ -0,0 +1,431 @@
<script lang="ts">
import { selectedItems, updateCanvasItem, removeSelectedItems } from '$lib/stores/canvas';
import { updateBoardItem, changeBoardItemZIndex } from '$lib/api/boardItems';
import Button from '$lib/components/ui/Button.svelte';
import { showToast } from '$lib/stores/toast';
const selectedItem = $derived($selectedItems[0] || null);
const hasMultipleSelected = $derived($selectedItems.length > 1);
// Local state for inputs (synced with selected item)
let positionX = $state(0);
let positionY = $state(0);
let scaleX = $state(100);
let scaleY = $state(100);
let rotation = $state(0);
let opacity = $state(100);
let lockAspectRatio = $state(true);
// Update local state when selection changes
$effect(() => {
if (selectedItem) {
positionX = Math.round(selectedItem.position_x);
positionY = Math.round(selectedItem.position_y);
scaleX = Math.round(selectedItem.scale_x * 100);
scaleY = Math.round(selectedItem.scale_y * 100);
rotation = Math.round(selectedItem.rotation);
opacity = Math.round(selectedItem.opacity * 100);
}
});
async function handlePositionChange(axis: 'x' | 'y', value: number) {
if (!selectedItem) return;
const updates = axis === 'x'
? { position_x: value }
: { position_y: value };
updateCanvasItem(selectedItem.id, updates);
try {
await updateBoardItem(selectedItem.id, updates);
} catch (error) {
console.error('Error updating position:', error);
showToast('Fehler beim Speichern', 'error');
}
}
async function handleScaleChange(axis: 'x' | 'y', percent: number) {
if (!selectedItem) return;
const scale = percent / 100;
let updates: any = {};
if (lockAspectRatio) {
updates = { scale_x: scale, scale_y: scale };
scaleX = percent;
scaleY = percent;
} else {
updates = axis === 'x' ? { scale_x: scale } : { scale_y: scale };
}
updateCanvasItem(selectedItem.id, updates);
try {
await updateBoardItem(selectedItem.id, updates);
} catch (error) {
console.error('Error updating scale:', error);
showToast('Fehler beim Speichern', 'error');
}
}
async function handleRotationChange(value: number) {
if (!selectedItem) return;
const updates = { rotation: value };
updateCanvasItem(selectedItem.id, updates);
try {
await updateBoardItem(selectedItem.id, updates);
} catch (error) {
console.error('Error updating rotation:', error);
showToast('Fehler beim Speichern', 'error');
}
}
async function handleOpacityChange(percent: number) {
if (!selectedItem) return;
const opacityValue = percent / 100;
const updates = { opacity: opacityValue };
updateCanvasItem(selectedItem.id, updates);
try {
await updateBoardItem(selectedItem.id, updates);
} catch (error) {
console.error('Error updating opacity:', error);
showToast('Fehler beim Speichern', 'error');
}
}
async function handleLayerChange(direction: 'up' | 'down' | 'top' | 'bottom') {
if (!selectedItem) return;
try {
await changeBoardItemZIndex(selectedItem.id, direction);
showToast('Layer-Reihenfolge geändert', 'success');
} catch (error) {
console.error('Error changing layer:', error);
showToast('Fehler beim Ändern der Layer-Reihenfolge', 'error');
}
}
function handleResetTransform() {
if (!selectedItem) return;
positionX = 0;
positionY = 0;
scaleX = 100;
scaleY = 100;
rotation = 0;
opacity = 100;
handlePositionChange('x', 0);
handlePositionChange('y', 0);
handleScaleChange('x', 100);
handleRotationChange(0);
handleOpacityChange(100);
}
function handleDelete() {
removeSelectedItems();
showToast('Bild entfernt', 'success');
}
</script>
{#if hasMultipleSelected}
<!-- Multiple Selection Info -->
<div class="h-full overflow-y-auto border-l border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">
{$selectedItems.length} Bilder ausgewählt
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Multi-Bearbeitung wird bald unterstützt
</p>
<Button variant="danger" class="mt-6" onclick={handleDelete}>
Alle löschen
</Button>
</div>
</div>
{:else if selectedItem}
<!-- Single Item Properties -->
<div class="h-full overflow-y-auto border-l border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
<div class="p-6">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-gray-100">
Bild-Eigenschaften
</h3>
<!-- Image Preview -->
<div class="mb-6 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<img
src={selectedItem.image.public_url}
alt="Preview"
class="w-full"
/>
</div>
<!-- Prompt Info -->
{#if selectedItem.image.prompt}
<div class="mb-6">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Prompt
</label>
<p class="rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{selectedItem.image.prompt}
</p>
</div>
{/if}
<!-- Position -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Position
</label>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">X</label>
<input
type="number"
bind:value={positionX}
onchange={() => handlePositionChange('x', positionX)}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">Y</label>
<input
type="number"
bind:value={positionY}
onchange={() => handlePositionChange('y', positionY)}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
</div>
</div>
<!-- Scale -->
<div class="mb-6">
<div class="mb-2 flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Skalierung
</label>
<button
onclick={() => (lockAspectRatio = !lockAspectRatio)}
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
{lockAspectRatio ? '🔒 Gesperrt' : '🔓 Frei'}
</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">Breite %</label>
<input
type="number"
bind:value={scaleX}
onchange={() => handleScaleChange('x', scaleX)}
min="1"
max="500"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">Höhe %</label>
<input
type="number"
bind:value={scaleY}
onchange={() => handleScaleChange('y', scaleY)}
min="1"
max="500"
disabled={lockAspectRatio}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
</div>
<input
type="range"
bind:value={scaleX}
oninput={() => handleScaleChange('x', scaleX)}
min="10"
max="300"
class="mt-3 w-full"
/>
</div>
<!-- Rotation -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Rotation: {rotation}°
</label>
<input
type="range"
bind:value={rotation}
oninput={() => handleRotationChange(rotation)}
min="0"
max="360"
class="w-full"
/>
<div class="mt-2 grid grid-cols-4 gap-2">
<button
onclick={() => { rotation = 0; handleRotationChange(0); }}
class="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
>
</button>
<button
onclick={() => { rotation = 90; handleRotationChange(90); }}
class="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
>
90°
</button>
<button
onclick={() => { rotation = 180; handleRotationChange(180); }}
class="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
>
180°
</button>
<button
onclick={() => { rotation = 270; handleRotationChange(270); }}
class="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
>
270°
</button>
</div>
</div>
<!-- Opacity -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Deckkraft: {opacity}%
</label>
<input
type="range"
bind:value={opacity}
oninput={() => handleOpacityChange(opacity)}
min="0"
max="100"
class="w-full"
/>
</div>
<!-- Layer Order -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Layer-Reihenfolge
</label>
<div class="grid grid-cols-2 gap-2">
<button
onclick={() => handleLayerChange('top')}
class="flex items-center justify-center gap-2 rounded-lg border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
Nach vorne
</button>
<button
onclick={() => handleLayerChange('bottom')}
class="flex items-center justify-center gap-2 rounded-lg border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
<svg class="h-4 w-4" 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>
Nach hinten
</button>
<button
onclick={() => handleLayerChange('up')}
class="flex items-center justify-center gap-2 rounded-lg border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
Eine Ebene
</button>
<button
onclick={() => handleLayerChange('down')}
class="flex items-center justify-center gap-2 rounded-lg border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
Eine Ebene
</button>
</div>
</div>
<!-- Dimensions Info -->
<div class="mb-6 rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
<div class="text-xs text-gray-500 dark:text-gray-400">
<div class="flex justify-between py-1">
<span>Original:</span>
<span class="font-medium">{selectedItem.image.width} × {selectedItem.image.height}px</span>
</div>
<div class="flex justify-between py-1">
<span>Aktuell:</span>
<span class="font-medium">
{Math.round((selectedItem.image.width || 0) * selectedItem.scale_x)} ×
{Math.round((selectedItem.image.height || 0) * selectedItem.scale_y)}px
</span>
</div>
<div class="flex justify-between py-1">
<span>Z-Index:</span>
<span class="font-medium">{selectedItem.z_index}</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="space-y-2">
<Button variant="outline" class="w-full" onclick={handleResetTransform}>
Transform zurücksetzen
</Button>
<Button variant="danger" class="w-full" onclick={handleDelete}>
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Bild entfernen
</Button>
</div>
</div>
</div>
{:else}
<!-- No Selection -->
<div class="flex h-full items-center justify-center border-l border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
/>
</svg>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Wähle ein Bild aus, um<br />seine Eigenschaften zu bearbeiten
</p>
</div>
</div>
{/if}

View file

@ -0,0 +1,41 @@
<script lang="ts">
interface Props {
size?: number;
class?: string;
}
let { size = 48, class: className = '' }: Props = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class={className}
>
<!-- Camera/Picture Frame -->
<rect x="4" y="8" width="40" height="32" rx="4" fill="currentColor" opacity="0.1" />
<rect x="4" y="8" width="40" height="32" rx="4" stroke="currentColor" stroke-width="2" />
<!-- Mountain/Landscape -->
<path
d="M8 36L18 22L24 30L32 18L40 36H8Z"
fill="currentColor"
opacity="0.2"
/>
<path
d="M8 36L18 22L24 30L32 18L40 36"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Sun -->
<circle cx="36" cy="16" r="4" fill="currentColor" />
<!-- Lens aperture hint at top -->
<circle cx="24" cy="4" r="2" fill="currentColor" />
</svg>

View file

@ -0,0 +1,64 @@
<script lang="ts">
import type { Database } from '@picture/shared/types';
import ImageCard from './ImageCard.svelte';
import { selectedImage } from '$lib/stores/images';
import { viewMode, type ViewMode } from '$lib/stores/view';
type Image = Database['public']['Tables']['images']['Row'];
interface Props {
images: Image[];
}
let { images }: Props = $props();
function handleImageClick(image: Image) {
selectedImage.set(image);
}
function getGridClass(mode: ViewMode) {
switch (mode) {
case 'single':
return 'grid grid-cols-1 gap-4 max-w-2xl mx-auto';
case 'grid3':
return 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3';
case 'grid5':
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5';
}
}
</script>
{#if images.length === 0}
<div class="flex min-h-[400px] items-center justify-center rounded-lg bg-white p-8 shadow">
<div class="text-center">
<svg
class="mx-auto h-24 w-24 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h2 class="mt-4 text-xl font-semibold text-gray-900">No images yet</h2>
<p class="mt-2 text-gray-600">Start generating AI images to see them here.</p>
<a
href="/app/generate"
class="mt-6 inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Generate Image
</a>
</div>
</div>
{:else}
<!-- Gallery Grid with dynamic view mode -->
<div class={getGridClass($viewMode)}>
{#each images as image (image.id)}
<ImageCard {image} onclick={() => handleImageClick(image)} viewMode={$viewMode} />
{/each}
</div>
{/if}

View file

@ -0,0 +1,82 @@
<script lang="ts">
import type { Database } from '@picture/shared/types';
import type { ViewMode } from '$lib/stores/view';
import { showContextMenu } from '$lib/stores/contextMenu';
type Image = Database['public']['Tables']['images']['Row'];
interface Props {
image: Image;
onclick?: () => void;
viewMode?: ViewMode;
}
let { image, onclick, viewMode = 'grid3' }: Props = $props();
let imageLoaded = $state(false);
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, image);
}
function formatDate(dateString: string) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric'
}).format(date);
}
function handleImageLoad() {
imageLoaded = true;
}
// Different aspect ratios based on view mode
const aspectClass = $derived(
viewMode === 'single' ? 'aspect-[4/3]' : 'aspect-square'
);
// Text size based on view mode
const textSizeClass = $derived(
viewMode === 'single' ? 'text-base' : viewMode === 'grid3' ? 'text-sm' : 'text-xs'
);
</script>
<button
class="group relative overflow-hidden rounded-lg bg-gray-100 transition-all hover:shadow-xl dark:bg-gray-800"
{onclick}
oncontextmenu={handleContextMenu}
type="button"
>
<div class="w-full {aspectClass}">
<img
src={image.public_url}
alt={image.prompt}
class="h-full w-full object-cover transition-opacity duration-300 {imageLoaded ? 'opacity-100' : 'opacity-15'}"
loading="lazy"
onload={handleImageLoad}
/>
</div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 transition-opacity group-hover:opacity-100"
>
<div class="absolute bottom-0 left-0 right-0 p-4 text-left">
<p class="mb-1 font-medium text-white text-base">
{image.prompt}
</p>
{#if viewMode !== 'grid5'}
<p class="text-sm text-white/80">
{formatDate(image.created_at)}
</p>
{/if}
</div>
</div>
{#if image.archived_at}
<div class="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-1 text-xs text-white">
Archived
</div>
{/if}
</button>

View file

@ -0,0 +1,664 @@
<script lang="ts">
import type { Database } from '@picture/shared/types';
import { archiveImage, deleteImage, downloadImage, publishImage, unpublishImage } from '$lib/api/images';
import { images, selectedImage } from '$lib/stores/images';
import { showToast } from '$lib/stores/toast';
import { fade, fly } from 'svelte/transition';
import { getImageTags, getAllTags, addTagToImage, removeTagFromImage } from '$lib/api/tags';
type Image = Database['public']['Tables']['images']['Row'];
type Tag = Database['public']['Tables']['tags']['Row'];
interface Props {
image: Image | null;
onClose: () => void;
}
let { image, onClose }: Props = $props();
let isArchiving = $state(false);
let isDeleting = $state(false);
let imageTags = $state<Tag[]>([]);
let showInfo = $state(false);
let showTagModal = $state(false);
let showPublishModal = $state(false);
let allTags = $state<Tag[]>([]);
let isLoadingTags = $state(false);
let isPublishing = $state(false);
// Get current image index
const currentIndex = $derived(
image ? $images.findIndex((img) => img.id === image.id) : -1
);
const hasPrevious = $derived(currentIndex > 0);
const hasNext = $derived(currentIndex >= 0 && currentIndex < $images.length - 1);
// Load tags for current image
$effect(() => {
if (image) {
loadImageTags(image.id);
}
});
async function loadImageTags(imageId: string) {
try {
imageTags = await getImageTags(imageId);
} catch (error) {
console.error('Error loading image tags:', error);
}
}
function navigatePrevious() {
if (hasPrevious) {
selectedImage.set($images[currentIndex - 1]);
}
}
function navigateNext() {
if (hasNext) {
selectedImage.set($images[currentIndex + 1]);
}
}
function handleKeydown(e: KeyboardEvent) {
if (!image) return;
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
navigatePrevious();
break;
case 'ArrowRight':
navigateNext();
break;
case 'i':
case 'I':
showInfo = !showInfo;
break;
}
}
async function handleArchive() {
if (!image) return;
isArchiving = true;
try {
await archiveImage(image.id);
// Update store
images.update((current) => current.filter((img) => img.id !== image.id));
showToast('Bild erfolgreich archiviert', 'success');
onClose();
} catch (error) {
console.error('Error archiving image:', error);
showToast('Fehler beim Archivieren des Bildes', 'error');
} finally {
isArchiving = false;
}
}
async function handleDelete() {
if (!image) return;
if (!confirm('Bist du sicher, dass du dieses Bild löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.'))
return;
isDeleting = true;
try {
await deleteImage(image.id);
// Update store
images.update((current) => current.filter((img) => img.id !== image.id));
showToast('Bild erfolgreich gelöscht', 'success');
onClose();
} catch (error) {
console.error('Error deleting image:', error);
showToast('Fehler beim Löschen des Bildes', 'error');
} finally {
isDeleting = false;
}
}
function handleDownload() {
if (!image) return;
const filename = `picture-${image.id}.png`;
downloadImage(image.public_url, filename);
showToast('Download gestartet', 'success');
}
function formatDate(dateString: string) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
async function openTagModal() {
showTagModal = true;
isLoadingTags = true;
try {
allTags = await getAllTags();
} catch (error) {
console.error('Error loading tags:', error);
showToast('Fehler beim Laden der Tags', 'error');
} finally {
isLoadingTags = false;
}
}
function closeTagModal() {
showTagModal = false;
}
async function handleToggleTag(tag: Tag) {
if (!image) return;
const isTagged = imageTags.some((t) => t.id === tag.id);
try {
if (isTagged) {
await removeTagFromImage(image.id, tag.id);
imageTags = imageTags.filter((t) => t.id !== tag.id);
showToast('Tag entfernt', 'success');
} else {
await addTagToImage(image.id, tag.id);
imageTags = [...imageTags, tag];
showToast('Tag hinzugefügt', 'success');
}
} catch (error) {
console.error('Error toggling tag:', error);
showToast('Fehler beim Aktualisieren des Tags', 'error');
}
}
function openPublishModal() {
showPublishModal = true;
}
function closePublishModal() {
showPublishModal = false;
}
async function handlePublish() {
if (!image) return;
isPublishing = true;
try {
await publishImage(image.id);
// Update local image state
if (image) {
image = { ...image, is_public: true };
}
showToast('Bild erfolgreich veröffentlicht!', 'success');
closePublishModal();
} catch (error) {
console.error('Error publishing image:', error);
showToast('Fehler beim Veröffentlichen des Bildes', 'error');
} finally {
isPublishing = false;
}
}
async function handleUnpublish() {
if (!image) return;
isPublishing = true;
try {
await unpublishImage(image.id);
// Update local image state
if (image) {
image = { ...image, is_public: false };
}
showToast('Bild nicht mehr öffentlich', 'success');
closePublishModal();
} catch (error) {
console.error('Error unpublishing image:', error);
showToast('Fehler beim Entfernen der Veröffentlichung', 'error');
} finally {
isPublishing = false;
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if image}
<!-- Fullscreen Viewer -->
<div
class="fixed inset-0 z-50 bg-black"
transition:fade={{ duration: 200 }}
onclick={onClose}
role="dialog"
aria-modal="true"
>
<!-- Close Button -->
<button
onclick={onClose}
class="fixed right-4 top-4 z-[60] flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-xl transition-all hover:bg-white/20"
aria-label="Schließen"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Info Toggle -->
<button
onclick={(e) => {
e.stopPropagation();
showInfo = !showInfo;
}}
class="fixed right-4 top-20 z-[60] flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-xl transition-all hover:bg-white/20 {showInfo ? 'bg-white/20' : ''}"
aria-label="Info anzeigen"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Tags Button -->
<button
onclick={(e) => {
e.stopPropagation();
openTagModal();
}}
class="fixed right-4 top-36 z-[60] flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-xl transition-all hover:bg-white/20"
aria-label="Tags verwalten"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</button>
<!-- Download Button -->
<button
onclick={(e) => {
e.stopPropagation();
handleDownload();
}}
class="fixed right-4 top-52 z-[60] flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-xl transition-all hover:bg-white/20"
aria-label="Download"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
<!-- Publish Button -->
<button
onclick={(e) => {
e.stopPropagation();
openPublishModal();
}}
class="fixed right-4 top-[17rem] z-[60] flex h-12 w-12 items-center justify-center rounded-full transition-all {image?.is_public
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
: 'bg-white/10 text-white hover:bg-white/20'} backdrop-blur-xl"
aria-label="Veröffentlichen"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Main Image Container -->
<div class="flex h-full w-full items-center justify-center p-4 pb-16">
<!-- Previous Button -->
{#if hasPrevious}
<button
onclick={(e) => {
e.stopPropagation();
navigatePrevious();
}}
class="absolute left-4 top-1/2 z-[60] flex h-14 w-14 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-xl transition-all hover:bg-white/20"
aria-label="Vorheriges Bild"
>
<svg class="h-7 w-7" 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>
{/if}
<!-- Image -->
<img
src={image.public_url}
alt={image.prompt}
class="max-h-full max-w-full object-contain"
onclick={(e) => e.stopPropagation()}
/>
<!-- Next Button -->
{#if hasNext}
<button
onclick={(e) => {
e.stopPropagation();
navigateNext();
}}
class="absolute right-4 top-1/2 z-[60] flex h-14 w-14 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-xl transition-all hover:bg-white/20"
aria-label="Nächstes Bild"
>
<svg class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
{/if}
</div>
<!-- Bottom Bar with Info -->
<div class="fixed bottom-0 left-0 right-0 z-[60] p-4">
<div class="mx-auto max-w-4xl">
<!-- Prompt Preview (always visible) -->
<div
class="mb-2"
onclick={(e) => e.stopPropagation()}
>
<p class="text-center text-sm text-white/90">
{image.prompt}
</p>
</div>
<!-- Detailed Info Panel (toggleable) -->
{#if showInfo}
<div
class="rounded-2xl bg-white/10 p-6 backdrop-blur-xl"
onclick={(e) => e.stopPropagation()}
transition:fly={{ y: 20, duration: 200 }}
>
<div class="grid gap-4 md:grid-cols-2">
<!-- Left Column -->
<div class="space-y-4">
<div>
<h3 class="mb-1 text-xs font-semibold uppercase tracking-wide text-white/60">
Prompt
</h3>
<p class="text-sm text-white">{image.prompt}</p>
</div>
<div>
<h3 class="mb-1 text-xs font-semibold uppercase tracking-wide text-white/60">
Model
</h3>
<p class="text-sm text-white">{image.model_id || 'Unknown'}</p>
</div>
{#if imageTags.length > 0}
<div>
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-white/60">
Tags
</h3>
<div class="flex flex-wrap gap-2">
{#each imageTags as tag}
<span
class="inline-flex items-center gap-1.5 rounded-full bg-white/20 px-3 py-1 text-xs text-white backdrop-blur-xl"
>
{#if tag.color}
<span
class="h-2 w-2 rounded-full"
style="background-color: {tag.color};"
></span>
{/if}
{tag.name}
</span>
{/each}
</div>
</div>
{/if}
</div>
<!-- Right Column -->
<div class="space-y-4">
<div>
<h3 class="mb-1 text-xs font-semibold uppercase tracking-wide text-white/60">
Erstellt
</h3>
<p class="text-sm text-white">{formatDate(image.created_at)}</p>
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
onclick={handleDownload}
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-white/20 px-4 py-2.5 text-sm font-medium text-white backdrop-blur-xl transition-all hover:bg-white/30"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download
</button>
<button
onclick={handleArchive}
disabled={isArchiving || isDeleting}
class="flex items-center justify-center gap-2 rounded-lg bg-white/20 px-4 py-2.5 text-sm font-medium text-white backdrop-blur-xl transition-all hover:bg-white/30 disabled:opacity-50"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
</button>
<button
onclick={handleDelete}
disabled={isArchiving || isDeleting}
class="flex items-center justify-center gap-2 rounded-lg bg-red-500/20 px-4 py-2.5 text-sm font-medium text-white backdrop-blur-xl transition-all hover:bg-red-500/30 disabled:opacity-50"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Tag Modal -->
{#if showTagModal}
<div
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-4"
transition:fade={{ duration: 200 }}
onclick={closeTagModal}
role="dialog"
aria-modal="true"
>
<div
class="w-full max-w-lg rounded-2xl bg-white p-6 dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
transition:fly={{ y: 20, duration: 200 }}
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Tags verwalten</h2>
<button
onclick={closeTagModal}
class="flex h-8 w-8 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{#if isLoadingTags}
<div class="flex items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
</div>
{:else if allTags.length === 0}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">Keine Tags verfügbar</p>
{:else}
<div class="max-h-96 space-y-2 overflow-y-auto">
{#each allTags as tag}
{@const isSelected = imageTags.some((t) => t.id === tag.id)}
<button
onclick={() => handleToggleTag(tag)}
class="flex w-full items-center justify-between rounded-lg border p-3 text-left transition-all {isSelected
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-600'}"
>
<div class="flex items-center gap-3">
{#if tag.color}
<span
class="h-4 w-4 rounded-full"
style="background-color: {tag.color};"
></span>
{/if}
<span class="font-medium {isSelected ? 'text-blue-900 dark:text-blue-100' : 'text-gray-900 dark:text-gray-100'}">
{tag.name}
</span>
</div>
{#if isSelected}
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{/if}
</button>
{/each}
</div>
{/if}
<div class="mt-4 flex justify-end">
<button
onclick={closeTagModal}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
Fertig
</button>
</div>
</div>
</div>
{/if}
<!-- Publish Modal -->
{#if showPublishModal && image}
<div
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-4"
transition:fade={{ duration: 200 }}
onclick={closePublishModal}
role="dialog"
aria-modal="true"
>
<div
class="w-full max-w-md rounded-2xl bg-white p-6 dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
transition:fly={{ y: 20, duration: 200 }}
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{image.is_public ? 'Veröffentlichung entfernen' : 'Bild veröffentlichen'}
</h2>
<button
onclick={closePublishModal}
class="flex h-8 w-8 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{#if image.is_public}
<div class="mb-6">
<p class="text-gray-600 dark:text-gray-400">
Dieses Bild ist derzeit öffentlich und kann von anderen Nutzern im Explore-Bereich gesehen werden.
</p>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Möchtest du die Veröffentlichung entfernen?
</p>
</div>
<div class="flex gap-3">
<button
onclick={closePublishModal}
disabled={isPublishing}
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleUnpublish}
disabled={isPublishing}
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-600"
>
{#if isPublishing}
<div class="h-4 w-4 animate-spin rounded-full border-2 border-solid border-white border-r-transparent"></div>
{/if}
Entfernen
</button>
</div>
{:else}
<div class="mb-6">
<p class="text-gray-600 dark:text-gray-400">
Möchtest du dieses Bild veröffentlichen? Es wird dann im Explore-Bereich für alle Nutzer sichtbar sein.
</p>
<div class="mt-4 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-sm text-blue-800 dark:text-blue-300">
💡 Tipp: Füge Tags hinzu, damit andere Nutzer dein Bild leichter finden können.
</p>
</div>
</div>
<div class="flex gap-3">
<button
onclick={closePublishModal}
disabled={isPublishing}
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handlePublish}
disabled={isPublishing}
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
{#if isPublishing}
<div class="h-4 w-4 animate-spin rounded-full border-2 border-solid border-white border-r-transparent"></div>
{/if}
Veröffentlichen
</button>
</div>
{/if}
</div>
</div>
{/if}
{/if}

View file

@ -0,0 +1,372 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '$lib/stores/auth';
import { models, selectedModel, isLoadingModels } from '$lib/stores/models';
import { isGenerating, generationProgress, generationError } from '$lib/stores/generate';
import { isSidebarCollapsed } from '$lib/stores/sidebar';
import { getActiveModels } from '$lib/api/models';
import { generateImageAsync, subscribeToGenerationUpdates } from '$lib/api/generate-async';
import { showToast } from '$lib/stores/toast';
import { onMount } from 'svelte';
import AdvancedSettingsModal, { type AdvancedSettings, type AspectRatio } from '$lib/components/generate/AdvancedSettingsModal.svelte';
interface Props {
onGenerated?: () => void;
}
let { onGenerated }: Props = $props();
let prompt = $state('');
let isExpanded = $state(false);
let selectedModelId = $state('');
let showAdvancedSettings = $state(false);
// Advanced settings with defaults
let advancedSettings = $state<AdvancedSettings>({
imageCount: 1,
aspectRatio: { label: 'Quadratisch', value: 'square', width: 1024, height: 1024 },
steps: 50,
guidanceScale: 7.5
});
// Update advanced settings when model changes
$effect(() => {
if ($selectedModel) {
// Update defaults from model
advancedSettings.steps = $selectedModel.default_steps || 50;
advancedSettings.guidanceScale = parseFloat($selectedModel.default_guidance_scale) || 7.5;
}
});
onMount(async () => {
await loadModels();
});
async function loadModels() {
isLoadingModels.set(true);
try {
const data = await getActiveModels();
models.set(data);
// Select default model
const defaultModel = data.find((m) => m.is_default) || data[0];
if (defaultModel) {
selectedModelId = defaultModel.id;
selectedModel.set(defaultModel);
}
} catch (error) {
console.error('Error loading models:', error);
showToast('Fehler beim Laden der Modelle', 'error');
} finally {
isLoadingModels.set(false);
}
}
function handleModelChange() {
const model = $models.find((m) => m.id === selectedModelId);
selectedModel.set(model || null);
}
async function handleQuickGenerate() {
if (!$user || !selectedModelId || !prompt.trim()) return;
isGenerating.set(true);
generationError.set('');
generationProgress.set('Generiere...');
try {
// Generate images based on imageCount
const totalImages = advancedSettings.imageCount;
let completedImages = 0;
for (let i = 0; i < totalImages; i++) {
generationProgress.set(`Generiere Bild ${i + 1}/${totalImages}...`);
// Start generation with new async API
const { generationId } = await generateImageAsync({
prompt: prompt.trim(),
model_id: selectedModelId,
width: advancedSettings.aspectRatio.width,
height: advancedSettings.aspectRatio.height,
num_inference_steps: advancedSettings.steps,
guidance_scale: advancedSettings.guidanceScale
});
// Wait for completion using realtime subscription
await new Promise<void>((resolve, reject) => {
const unsubscribe = subscribeToGenerationUpdates(generationId, (progress) => {
// Update progress display
if (progress.status === 'pending' || progress.status === 'queued') {
generationProgress.set(`Warte auf Verarbeitung... (${i + 1}/${totalImages})`);
} else if (progress.status === 'processing') {
generationProgress.set(`Verarbeite Bild ${i + 1}/${totalImages}...`);
} else if (progress.status === 'downloading') {
generationProgress.set(`Speichere Bild ${i + 1}/${totalImages}...`);
} else if (progress.status === 'completed') {
unsubscribe();
resolve();
} else if (progress.status === 'failed') {
unsubscribe();
reject(new Error(progress.error || 'Bild-Generierung fehlgeschlagen'));
}
});
// Timeout after 10 minutes
setTimeout(() => {
unsubscribe();
reject(new Error('Timeout - bitte später in der Galerie prüfen'));
}, 600000);
});
completedImages++;
}
// Success
generationProgress.set('Fertig!');
showToast(
totalImages > 1
? `${totalImages} Bilder erfolgreich generiert!`
: 'Bild erfolgreich generiert!',
'success'
);
prompt = '';
isExpanded = false;
onGenerated?.();
} catch (error) {
console.error('Generation error:', error);
const errorMessage = error instanceof Error ? error.message : 'Generierung fehlgeschlagen';
generationError.set(errorMessage);
showToast(errorMessage, 'error');
} finally {
setTimeout(() => {
isGenerating.set(false);
generationProgress.set('');
}, 1000);
}
}
function handleSettingsUpdate(settings: AdvancedSettings) {
advancedSettings = settings;
}
function handleKeyPress(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleQuickGenerate();
}
}
const canGenerate = $derived(
!$isGenerating && !$isLoadingModels && prompt.trim().length > 0 && selectedModelId.length > 0
);
// Check if advanced settings differ from defaults
const hasCustomSettings = $derived(
advancedSettings.imageCount !== 1 ||
advancedSettings.aspectRatio.value !== 'square' ||
advancedSettings.steps !== 50 ||
advancedSettings.guidanceScale !== 7.5
);
</script>
<!-- Quick Generate Bar - Floating -->
<div
class="fixed z-40 transition-all duration-300 {isExpanded
? 'bottom-0 left-0 right-0 lg:bottom-8 lg:right-auto lg:-translate-x-1/2 ' +
($isSidebarCollapsed ? 'lg:left-1/2' : 'lg:left-[calc(50%+8.5rem)]')
: 'bottom-8 right-8'}"
>
{#if isExpanded}
<div class="lg:w-[900px] animate-in fade-in slide-in-from-bottom-4 duration-200">
<!-- Main Bar (expanded) -->
<div class="rounded-t-3xl border-t border-gray-200/50 bg-white/80 shadow-2xl backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/80 lg:rounded-3xl lg:border">
<div class="p-4">
<!-- Error Display -->
{#if $generationError}
<div class="mb-3 rounded-lg bg-red-50 p-3 dark:bg-red-900/20">
<p class="text-sm text-red-800 dark:text-red-300">{$generationError}</p>
</div>
{/if}
<!-- Progress Display -->
{#if $isGenerating}
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<div class="flex items-center">
<div
class="mr-3 h-4 w-4 animate-spin rounded-full border-2 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
<p class="text-sm text-blue-800 dark:text-blue-300">{$generationProgress}</p>
</div>
</div>
{/if}
<!-- Single Row Layout -->
<div class="flex items-center gap-3">
<!-- Model Selection -->
<select
id="quick-model"
bind:value={selectedModelId}
onchange={handleModelChange}
disabled={$isGenerating || $isLoadingModels}
class="w-48 flex-shrink-0 rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 transition-colors focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:disabled:bg-gray-700"
>
{#if $isLoadingModels}
<option value="">Lade Modelle...</option>
{:else if $models.length === 0}
<option value="">Keine Modelle verfügbar</option>
{:else}
{#each $models as model}
<option value={model.id}>
{model.name}
{model.is_default ? '(Standard)' : ''}
</option>
{/each}
{/if}
</select>
<!-- Input -->
<div class="relative h-12 flex-1">
<textarea
bind:value={prompt}
onkeydown={handleKeyPress}
disabled={$isGenerating}
placeholder="Beschreibe dein Bild..."
rows="1"
class="h-full w-full resize-none rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 placeholder-gray-500 transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:border-blue-400 dark:disabled:bg-gray-700"
style="max-height: 120px;"
></textarea>
<!-- Character count hint -->
{#if prompt.length > 400}
<span class="absolute bottom-2 right-3 text-xs text-orange-600 dark:text-orange-400">
{prompt.length}/500
</span>
{/if}
</div>
<!-- Settings Button -->
<button
onclick={(e) => {
e.stopPropagation();
showAdvancedSettings = true;
}}
disabled={$isGenerating}
class="relative flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-gray-100/80 text-gray-600 backdrop-blur-xl transition-all hover:bg-gray-200/80 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800/80 dark:text-gray-400 dark:hover:bg-gray-700/80"
aria-label="Einstellungen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{#if hasCustomSettings}
<span class="absolute right-0 top-0 flex h-3 w-3">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"></span>
<span class="relative inline-flex h-3 w-3 rounded-full bg-blue-600 dark:bg-blue-500"></span>
</span>
{/if}
</button>
<!-- Generate Button -->
<button
onclick={handleQuickGenerate}
disabled={!canGenerate}
class="flex h-12 flex-shrink-0 items-center justify-center gap-2 rounded-2xl px-6 backdrop-blur-xl transition-all disabled:cursor-not-allowed disabled:opacity-50 {canGenerate
? 'bg-blue-600/90 hover:bg-blue-700/90 dark:bg-blue-500/90 dark:hover:bg-blue-600/90'
: 'bg-gray-300/80 dark:bg-gray-700/80'}"
aria-label="Generieren"
>
{#if $isGenerating}
<div
class="h-5 w-5 animate-spin rounded-full border-2 border-solid border-white border-r-transparent"
></div>
{:else}
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span class="text-sm font-medium text-white">Generieren</span>
{/if}
</button>
<!-- Close Button -->
<button
onclick={() => (isExpanded = false)}
disabled={$isGenerating}
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-gray-100/80 text-gray-600 backdrop-blur-xl transition-all hover:bg-gray-200/80 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800/80 dark:text-gray-400 dark:hover:bg-gray-700/80"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
{:else}
<!-- Collapsed Button (bottom right) -->
<div class="hidden animate-in fade-in slide-in-from-bottom-4 duration-200 lg:block">
<button
onclick={() => (isExpanded = true)}
disabled={$isGenerating}
class="flex h-14 items-center justify-center gap-2 rounded-full bg-blue-600/90 px-6 text-white shadow-2xl backdrop-blur-xl transition-all hover:scale-105 hover:bg-blue-700/90 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500/90 dark:hover:bg-blue-600/90"
aria-label="Generieren"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span class="text-sm font-medium">Bild generieren</span>
</button>
</div>
<!-- Mobile: Always show bar at bottom -->
<div class="lg:hidden">
<div class="rounded-t-3xl border-t border-gray-200/50 bg-white/80 shadow-2xl backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/80">
<div class="flex items-center gap-3 p-4">
<button
onclick={() => (isExpanded = true)}
disabled={$isGenerating}
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-600/90 text-white backdrop-blur-xl transition-all hover:bg-blue-700/90 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500/90 dark:hover:bg-blue-600/90"
aria-label="Erweitern"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</button>
<div class="flex-1 text-sm text-gray-600 dark:text-gray-400">Bild generieren...</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Advanced Settings Modal -->
<AdvancedSettingsModal
isOpen={showAdvancedSettings}
onClose={() => (showAdvancedSettings = false)}
settings={advancedSettings}
onUpdate={handleSettingsUpdate}
/>

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition';
export interface AspectRatio {
label: string;
value: string;
width: number;
height: number;
}
export interface AdvancedSettings {
imageCount: number;
aspectRatio: AspectRatio;
steps: number;
guidanceScale: number;
}
interface Props {
isOpen: boolean;
onClose: () => void;
settings: AdvancedSettings;
onUpdate: (settings: AdvancedSettings) => void;
}
let { isOpen, onClose, settings, onUpdate }: Props = $props();
// Available aspect ratios (compatible with most models)
const aspectRatios: AspectRatio[] = [
{ label: 'Quadratisch', value: 'square', width: 1024, height: 1024 },
{ label: 'Hochformat', value: 'portrait', width: 768, height: 1344 },
{ label: 'Querformat', value: 'landscape', width: 1344, height: 768 }
];
// Local state
let localSettings = $state<AdvancedSettings>({ ...settings });
// Update local settings when props change
$effect(() => {
if (isOpen) {
localSettings = { ...settings };
}
});
function handleSave() {
onUpdate(localSettings);
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm"
transition:fade={{ duration: 200 }}
onclick={onClose}
role="presentation"
></div>
<!-- Modal -->
<div
class="fixed left-1/2 top-1/2 z-[80] w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-3xl border border-gray-200/50 bg-white/95 p-6 shadow-2xl backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/95"
transition:fly={{ y: 20, duration: 200 }}
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
Erweiterte Einstellungen
</h2>
<button
onclick={onClose}
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-6">
<!-- Image Count -->
<div>
<div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Anzahl Bilder
</label>
{#if localSettings.imageCount > 1}
<span class="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
{localSettings.imageCount} Bilder
</span>
{/if}
</div>
<div class="flex gap-2">
{#each [1, 2, 3, 4, 5] as count}
<button
onclick={() => (localSettings.imageCount = count)}
class="flex h-12 w-12 items-center justify-center rounded-xl border-2 font-medium transition-all {localSettings.imageCount === count
? 'border-blue-600 bg-blue-600 text-white dark:border-blue-500 dark:bg-blue-500'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400'}"
>
{count}
</button>
{/each}
</div>
{#if localSettings.imageCount > 1}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Jedes Bild wird mit einem anderen Seed generiert
</p>
{/if}
</div>
<!-- Aspect Ratio -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-900 dark:text-gray-100">
Seitenverhältnis
</label>
<div class="grid grid-cols-3 gap-3">
{#each aspectRatios as ratio}
<button
onclick={() => (localSettings.aspectRatio = ratio)}
class="flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all {localSettings.aspectRatio.value === ratio.value
? 'border-blue-600 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20'
: 'border-gray-300 bg-white hover:border-blue-400 dark:border-gray-600 dark:bg-gray-800 dark:hover:border-blue-400'}"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg {localSettings.aspectRatio.value === ratio.value
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-gray-200 dark:bg-gray-700'}"
>
<div
class="rounded {localSettings.aspectRatio.value === ratio.value
? 'bg-white'
: 'bg-gray-400 dark:bg-gray-500'}"
style="width: {ratio.value === 'square'
? '24px'
: ratio.value === 'portrait'
? '16px'
: '32px'}; height: {ratio.value === 'square'
? '24px'
: ratio.value === 'portrait'
? '32px'
: '16px'};"
></div>
</div>
<div class="text-center">
<p
class="text-sm font-medium {localSettings.aspectRatio.value === ratio.value
? 'text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'}"
>
{ratio.label}
</p>
<p
class="text-xs {localSettings.aspectRatio.value === ratio.value
? 'text-blue-700 dark:text-blue-300'
: 'text-gray-500 dark:text-gray-400'}"
>
{ratio.width}×{ratio.height}
</p>
</div>
</button>
{/each}
</div>
</div>
<!-- Steps Slider -->
<div>
<div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Schritte (Steps)
</label>
<span class="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-800 dark:bg-gray-800 dark:text-gray-300">
{localSettings.steps}
</span>
</div>
<input
type="range"
min="20"
max="150"
step="5"
bind:value={localSettings.steps}
class="h-2 w-full appearance-none rounded-lg bg-gray-200 dark:bg-gray-700 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:dark:bg-blue-500 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:dark:bg-blue-500"
/>
<div class="mt-2 flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>20 (Schnell)</span>
<span>150 (Höchste Qualität)</span>
</div>
</div>
<!-- Guidance Scale Slider -->
<div>
<div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Guidance Scale
</label>
<span class="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-800 dark:bg-gray-800 dark:text-gray-300">
{localSettings.guidanceScale}
</span>
</div>
<input
type="range"
min="1"
max="20"
step="0.5"
bind:value={localSettings.guidanceScale}
class="h-2 w-full appearance-none rounded-lg bg-gray-200 dark:bg-gray-700 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:dark:bg-blue-500 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:dark:bg-blue-500"
/>
<div class="mt-2 flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>1 (Kreativ)</span>
<span>20 (Präzise)</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Höhere Werte folgen dem Prompt genauer, niedrigere sind kreativer
</p>
</div>
</div>
<!-- Actions -->
<div class="mt-8 flex gap-3">
<button
onclick={onClose}
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleSave}
class="flex-1 rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
Übernehmen
</button>
</div>
</div>
{/if}

View file

@ -0,0 +1,242 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores/auth';
import { models, selectedModel, isLoadingModels } from '$lib/stores/models';
import { isGenerating, generationProgress, generationError } from '$lib/stores/generate';
import { getActiveModels } from '$lib/api/models';
import { generateImageAsync, subscribeToGenerationUpdates } from '$lib/api/generate-async';
import Button from '../ui/Button.svelte';
import Card from '../ui/Card.svelte';
let prompt = $state('');
let negativePrompt = $state('');
let selectedModelId = $state('');
const MAX_PROMPT_LENGTH = 500;
const MAX_NEGATIVE_PROMPT_LENGTH = 200;
onMount(async () => {
await loadModels();
});
async function loadModels() {
isLoadingModels.set(true);
try {
const data = await getActiveModels();
models.set(data);
// Select default model
const defaultModel = data.find((m) => m.is_default) || data[0];
if (defaultModel) {
selectedModelId = defaultModel.id;
selectedModel.set(defaultModel);
}
} catch (error) {
console.error('Error loading models:', error);
generationError.set('Failed to load models');
} finally {
isLoadingModels.set(false);
}
}
function handleModelChange() {
const model = $models.find((m) => m.id === selectedModelId);
selectedModel.set(model || null);
}
async function handleGenerate() {
if (!$user || !selectedModelId || !prompt.trim()) return;
isGenerating.set(true);
generationError.set('');
generationProgress.set('Starting generation...');
try {
// Start generation with new async API
const { generationId } = await generateImageAsync({
prompt: prompt.trim(),
model_id: selectedModelId,
negative_prompt: negativePrompt.trim() || undefined
});
// Wait for completion using realtime subscription
await new Promise<void>((resolve, reject) => {
const unsubscribe = subscribeToGenerationUpdates(generationId, (progress) => {
// Update progress display
if (progress.status === 'pending' || progress.status === 'queued') {
generationProgress.set('Queued for processing...');
} else if (progress.status === 'processing') {
generationProgress.set('Processing...');
} else if (progress.status === 'downloading') {
generationProgress.set('Saving image...');
} else if (progress.status === 'completed') {
unsubscribe();
resolve();
} else if (progress.status === 'failed') {
unsubscribe();
reject(new Error(progress.error || 'Image generation failed'));
}
});
// Timeout after 10 minutes
setTimeout(() => {
unsubscribe();
reject(new Error('Generation timeout - please check your gallery later'));
}, 600000);
});
// Success - redirect to gallery
generationProgress.set('Complete!');
setTimeout(() => {
goto('/app/gallery');
}, 1000);
} catch (error) {
console.error('Generation error:', error);
generationError.set(
error instanceof Error ? error.message : 'Failed to generate image'
);
} finally {
isGenerating.set(false);
}
}
const promptLength = $derived(prompt.length);
const negativePromptLength = $derived(negativePrompt.length);
const canGenerate = $derived(
!$isGenerating &&
!$isLoadingModels &&
prompt.trim().length > 0 &&
selectedModelId.length > 0
);
</script>
<Card>
<div class="space-y-6 p-6">
<h2 class="text-2xl font-bold text-gray-900">Generate Image</h2>
{#if $generationError}
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-800">{$generationError}</p>
</div>
</div>
</div>
{/if}
{#if $isGenerating}
<div class="rounded-md bg-blue-50 p-4">
<div class="flex items-center">
<div
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-solid border-blue-600 border-r-transparent"
></div>
<p class="text-sm text-blue-800">{$generationProgress}</p>
</div>
</div>
{/if}
<!-- Model Selection -->
<div>
<label for="model" class="mb-2 block text-sm font-medium text-gray-700">
Model <span class="text-red-500">*</span>
</label>
<select
id="model"
bind:value={selectedModelId}
onchange={handleModelChange}
disabled={$isGenerating || $isLoadingModels}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-50"
>
{#if $isLoadingModels}
<option value="">Loading models...</option>
{:else if $models.length === 0}
<option value="">No models available</option>
{:else}
{#each $models as model}
<option value={model.id}>
{model.name}
{model.is_default ? '(Default)' : ''}
</option>
{/each}
{/if}
</select>
{#if $selectedModel}
<p class="mt-1 text-sm text-gray-500">{$selectedModel.description || ''}</p>
{/if}
</div>
<!-- Prompt -->
<div>
<label for="prompt" class="mb-2 block text-sm font-medium text-gray-700">
Prompt <span class="text-red-500">*</span>
</label>
<textarea
id="prompt"
bind:value={prompt}
disabled={$isGenerating}
maxlength={MAX_PROMPT_LENGTH}
rows="4"
placeholder="Describe the image you want to generate..."
class="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-50"
></textarea>
<div class="mt-1 flex justify-between text-sm text-gray-500">
<span>Be specific and descriptive for best results</span>
<span class={promptLength > MAX_PROMPT_LENGTH - 50 ? 'text-orange-600' : ''}>
{promptLength}/{MAX_PROMPT_LENGTH}
</span>
</div>
</div>
<!-- Negative Prompt -->
<div>
<label for="negative-prompt" class="mb-2 block text-sm font-medium text-gray-700">
Negative Prompt (Optional)
</label>
<textarea
id="negative-prompt"
bind:value={negativePrompt}
disabled={$isGenerating}
maxlength={MAX_NEGATIVE_PROMPT_LENGTH}
rows="2"
placeholder="What to avoid in the image (e.g., blurry, low quality, distorted)..."
class="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-50"
></textarea>
<div class="mt-1 flex justify-between text-sm text-gray-500">
<span>Elements to exclude from the image</span>
<span
class={negativePromptLength > MAX_NEGATIVE_PROMPT_LENGTH - 20 ? 'text-orange-600' : ''}
>
{negativePromptLength}/{MAX_NEGATIVE_PROMPT_LENGTH}
</span>
</div>
</div>
<!-- Generate Button -->
<Button
variant="primary"
class="w-full"
onclick={handleGenerate}
disabled={!canGenerate}
loading={$isGenerating}
>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
{$isGenerating ? 'Generating...' : 'Generate Image'}
</Button>
</div>
</Card>

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import { supabase } from '$lib/supabase';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let showUserMenu = $state(false);
let showMobileMenu = $state(false);
async function handleLogout() {
await supabase.auth.signOut();
goto('/auth/login');
}
function isActive(path: string) {
return $page.url.pathname === path;
}
</script>
<header class="border-b border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
<div class="container mx-auto px-4">
<div class="flex h-16 items-center justify-between">
<!-- Logo -->
<a href="/app/gallery" class="text-xl font-bold text-gray-900 dark:text-gray-100"> Picture </a>
<!-- Mobile Menu Button -->
<button
onclick={() => (showMobileMenu = !showMobileMenu)}
class="flex items-center justify-center rounded-lg p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 md:hidden"
aria-label="Menu"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={showMobileMenu ? 'M6 18L18 6M6 6l12 12' : 'M4 6h16M4 12h16M4 18h16'}
/>
</svg>
</button>
<!-- Desktop Navigation -->
<nav class="hidden items-center gap-6 md:flex">
<a
href="/app/gallery"
class="text-sm font-medium transition-colors {isActive('/app/gallery')
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
Gallery
</a>
<a
href="/app/explore"
class="text-sm font-medium transition-colors {isActive('/app/explore')
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
Entdecken
</a>
<a
href="/app/generate"
class="text-sm font-medium transition-colors {isActive('/app/generate')
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
Generieren
</a>
<a
href="/app/archive"
class="text-sm font-medium transition-colors {isActive('/app/archive')
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
Archiv
</a>
</nav>
<!-- User Menu -->
<div class="relative">
<button
onclick={() => (showUserMenu = !showUserMenu)}
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-white dark:bg-blue-500">
{$user?.email?.charAt(0).toUpperCase()}
</div>
<svg
class="h-4 w-4"
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="absolute right-0 mt-2 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<div class="border-b border-gray-100 px-4 py-2 dark:border-gray-700">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">{$user?.email}</p>
</div>
<a
href="/app/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Profile
</a>
<button
onclick={handleLogout}
class="block w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-gray-100 dark:text-red-400 dark:hover:bg-gray-700"
>
Sign Out
</button>
</div>
{/if}
</div>
</div>
<!-- Mobile Menu -->
{#if showMobileMenu}
<div class="border-t border-gray-200 pb-4 pt-2 dark:border-gray-700 md:hidden">
<nav class="flex flex-col space-y-1">
<a
href="/app/gallery"
onclick={() => (showMobileMenu = false)}
class="block px-4 py-2 text-sm font-medium transition-colors {isActive(
'/app/gallery'
)
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800'}"
>
Gallery
</a>
<a
href="/app/explore"
onclick={() => (showMobileMenu = false)}
class="block px-4 py-2 text-sm font-medium transition-colors {isActive(
'/app/explore'
)
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800'}"
>
Entdecken
</a>
<a
href="/app/generate"
onclick={() => (showMobileMenu = false)}
class="block px-4 py-2 text-sm font-medium transition-colors {isActive(
'/app/generate'
)
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800'}"
>
Generieren
</a>
<a
href="/app/archive"
onclick={() => (showMobileMenu = false)}
class="block px-4 py-2 text-sm font-medium transition-colors {isActive(
'/app/archive'
)
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800'}"
>
Archiv
</a>
<a
href="/app/profile"
onclick={() => (showMobileMenu = false)}
class="block px-4 py-2 text-sm font-medium transition-colors {isActive(
'/app/profile'
)
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800'}"
>
Profil
</a>
</nav>
</div>
{/if}
</div>
</header>

View file

@ -0,0 +1,594 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import { supabase } from '$lib/supabase';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { currentTheme } from '$lib/stores/theme';
import { viewMode, cycleViewMode, type ViewMode } from '$lib/stores/view';
import { isSidebarCollapsed, setSidebarCollapsed } from '$lib/stores/sidebar';
import {
exploreSearchQuery,
exploreSortBy,
isLoadingExplore,
exploreImages,
currentExplorePage,
hasMoreExplore,
showExploreFavoritesOnly
} from '$lib/stores/explore';
import { tags, selectedTags } from '$lib/stores/tags';
import { showFavoritesOnly } from '$lib/stores/images';
import { searchPublicImages, getPublicImages } from '$lib/api/explore';
import { showKeyboardShortcuts } from '$lib/stores/ui';
import TagPills from '$lib/components/tags/TagPills.svelte';
let showUserMenu = $state(false);
let searchInput = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
async function handleLogout() {
await supabase.auth.signOut();
goto('/auth/login');
}
function isActive(path: string) {
return $page.url.pathname === path;
}
function getViewModeIcon(mode: ViewMode) {
switch (mode) {
case 'single':
return 'M4 6h16M4 12h16M4 18h16';
case 'grid3':
return 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 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';
case 'grid5':
return 'M4 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM10 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM16 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5z';
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
searchInput = target.value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
exploreSearchQuery.set(searchInput);
if (searchInput.trim()) {
isLoadingExplore.set(true);
try {
const results = await searchPublicImages(searchInput, 1, 20, $showExploreFavoritesOnly);
exploreImages.set(results);
currentExplorePage.set(1);
hasMoreExplore.set(results.length === 20);
} catch (error) {
console.error('Search error:', error);
} finally {
isLoadingExplore.set(false);
}
} else {
isLoadingExplore.set(true);
try {
const data = await getPublicImages({
page: 1,
sortBy: $exploreSortBy,
favoritesOnly: $showExploreFavoritesOnly
});
exploreImages.set(data);
currentExplorePage.set(1);
hasMoreExplore.set(data.length === 20);
} finally {
isLoadingExplore.set(false);
}
}
}, 300);
}
async function handleSortChange(e: Event) {
const target = e.target as HTMLSelectElement;
exploreSortBy.set(target.value as 'recent' | 'popular' | 'trending');
isLoadingExplore.set(true);
try {
const data = await getPublicImages({
page: 1,
sortBy: target.value as any,
favoritesOnly: $showExploreFavoritesOnly
});
exploreImages.set(data);
currentExplorePage.set(1);
hasMoreExplore.set(data.length === 20);
} finally {
isLoadingExplore.set(false);
}
}
interface NavItem {
path: string;
label: string;
icon: string;
}
const navItems: NavItem[] = [
{
path: '/app/gallery',
label: 'Galerie',
icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'
},
{
path: '/app/board',
label: 'Moodboards',
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 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-3zM14 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1v-3z'
},
{
path: '/app/explore',
label: 'Entdecken',
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
},
{
path: '/app/generate',
label: 'Generieren',
icon: 'M13 10V3L4 14h7v7l9-11h-7z'
},
{
path: '/app/upload',
label: 'Upload',
icon: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12'
},
{
path: '/app/tags',
label: 'Tags',
icon: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z'
},
{
path: '/app/archive',
label: 'Archiv',
icon: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4'
},
{
path: '/app/subscription',
label: 'Abonnement',
icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
}
];
</script>
<!-- Sidebar Toggle Button (collapsed) -->
<button
onclick={() => setSidebarCollapsed(false)}
class="fixed bottom-8 left-4 z-50 hidden h-14 w-14 items-center justify-center rounded-full bg-blue-600/90 text-white shadow-2xl backdrop-blur-xl transition-all duration-300 hover:bg-blue-700/90 dark:bg-blue-500/90 dark:hover:bg-blue-600/90 lg:flex"
class:-translate-x-[calc(100%+2rem)]={!$isSidebarCollapsed}
aria-label="Sidebar öffnen"
>
<svg class="h-6 w-6" 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>
<!-- Sidebar for Desktop -->
<aside
class="fixed left-4 top-4 z-40 hidden h-[calc(100vh-2rem)] w-64 flex-col overflow-hidden rounded-3xl border border-gray-200/50 bg-white/80 shadow-2xl backdrop-blur-xl transition-transform duration-300 dark:border-gray-700/50 dark:bg-gray-900/80 lg:flex"
class:-translate-x-[calc(100%+2rem)]={$isSidebarCollapsed}
>
<!-- Logo & Collapse Button -->
<div class="flex h-16 flex-shrink-0 items-center justify-between border-b border-gray-200/50 px-6 dark:border-gray-700/50">
<a href="/app/gallery" class="text-2xl font-bold text-gray-900 dark:text-gray-100">
Picture
</a>
<button
onclick={() => setSidebarCollapsed(true)}
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 backdrop-blur-xl transition-colors hover:bg-gray-100/80 hover:text-gray-600 dark:hover:bg-gray-800/80 dark:hover:text-gray-300"
aria-label="Sidebar schließen"
>
<svg class="h-5 w-5" 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="flex-1 space-y-1 overflow-y-auto px-3 py-4">
{#each navItems as item}
{@const active = isActive(item.path)}
<a
href={item.path}
class="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all {active
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg
class="h-5 w-5 {active ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 group-hover:text-gray-600 dark:text-gray-500 dark:group-hover:text-gray-300'}"
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="my-4 border-t border-gray-200 dark:border-gray-700"></div>
<!-- Help / Keyboard Shortcuts -->
<button
onclick={() => showKeyboardShortcuts.set(true)}
class="group flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-700 transition-all hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg
class="h-5 w-5 text-gray-400 group-hover:text-gray-600 dark:text-gray-500 dark:group-hover:text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Tastaturkürzel</span>
</button>
<!-- Divider -->
<div class="my-4 border-t border-gray-200 dark:border-gray-700"></div>
<!-- View Mode Switcher -->
<div class="px-3 py-2">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Ansicht
</p>
<div class="grid grid-cols-3 gap-2">
<button
onclick={() => viewMode.set('single')}
class="flex items-center justify-center rounded-lg p-2 backdrop-blur-xl transition-colors {$viewMode === 'single'
? 'bg-blue-100/80 text-blue-600 dark:bg-blue-950/80 dark:text-blue-400'
: 'text-gray-400 hover:bg-gray-100/80 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800/80 dark:hover:text-gray-300'}"
title="Liste"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onclick={() => viewMode.set('grid3')}
class="flex items-center justify-center rounded-lg p-2 backdrop-blur-xl transition-colors {$viewMode === 'grid3'
? 'bg-blue-100/80 text-blue-600 dark:bg-blue-950/80 dark:text-blue-400'
: 'text-gray-400 hover:bg-gray-100/80 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800/80 dark:hover:text-gray-300'}"
title="Mittel"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 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" />
</svg>
</button>
<button
onclick={() => viewMode.set('grid5')}
class="flex items-center justify-center rounded-lg p-2 backdrop-blur-xl transition-colors {$viewMode === 'grid5'
? 'bg-blue-100/80 text-blue-600 dark:bg-blue-950/80 dark:text-blue-400'
: 'text-gray-400 hover:bg-gray-100/80 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800/80 dark:hover:text-gray-300'}"
title="Klein"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM10 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM16 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM4 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1v-2zM10 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2zM16 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2z" />
</svg>
</button>
</div>
</div>
<!-- Explore Controls (only on explore page) -->
{#if isActive('/app/explore')}
<div class="space-y-3 px-3 py-2">
<div class="border-t border-gray-200 pb-2 dark:border-gray-700"></div>
<div>
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Filter
</p>
{#if $showExploreFavoritesOnly}
<button
onclick={() => showExploreFavoritesOnly.set(false)}
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
>
Zurücksetzen
</button>
{/if}
</div>
<!-- Favorites Toggle -->
<button
onclick={() => showExploreFavoritesOnly.update(v => !v)}
class="mb-3 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors {$showExploreFavoritesOnly
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
>
<svg
class="h-4 w-4"
fill={$showExploreFavoritesOnly ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
</svg>
<span>Favoriten</span>
</button>
</div>
<div>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Suchen
</p>
<div class="relative">
<input
type="text"
value={searchInput}
oninput={handleSearchInput}
placeholder="Prompts..."
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 pl-9 text-sm text-gray-900 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
/>
<svg
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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"
/>
</svg>
</div>
</div>
<div>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Sortieren
</p>
<select
value={$exploreSortBy}
onchange={handleSortChange}
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
<option value="recent">Neueste</option>
<option value="popular">Beliebt</option>
<option value="trending">Im Trend</option>
</select>
</div>
</div>
{/if}
<!-- Gallery Filters (only on gallery page) -->
{#if isActive('/app/gallery')}
<div class="space-y-3 px-3 py-2">
<div class="border-t border-gray-200 pb-2 dark:border-gray-700"></div>
<div>
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Filter
</p>
{#if $selectedTags.length > 0 || $showFavoritesOnly}
<button
onclick={() => {
selectedTags.set([]);
showFavoritesOnly.set(false);
}}
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
>
Zurücksetzen
</button>
{/if}
</div>
<!-- Favorites Toggle -->
<button
onclick={() => showFavoritesOnly.update(v => !v)}
class="mb-3 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors {$showFavoritesOnly
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
>
<svg
class="h-4 w-4"
fill={$showFavoritesOnly ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
</svg>
<span>Favoriten</span>
</button>
{#if $tags.length > 0}
<TagPills />
{:else}
<p class="text-xs text-gray-500 dark:text-gray-400">
Keine Tags vorhanden
</p>
{/if}
</div>
{#if $selectedTags.length > 0 || $showFavoritesOnly}
<div class="rounded-lg bg-blue-50 p-2 dark:bg-blue-900/20">
<p class="text-xs font-medium text-blue-900 dark:text-blue-100">
{#if $showFavoritesOnly && $selectedTags.length > 0}
Favoriten + {$selectedTags.length} {$selectedTags.length === 1 ? 'Tag' : 'Tags'}
{:else if $showFavoritesOnly}
Favoriten
{:else}
{$selectedTags.length} {$selectedTags.length === 1 ? 'Tag' : 'Tags'} ausgewählt
{/if}
</p>
</div>
{/if}
</div>
{/if}
</nav>
<!-- User Section -->
<div class="flex-shrink-0 border-t border-gray-200/50 p-3 dark:border-gray-700/50">
<div class="relative">
<button
onclick={() => (showUserMenu = !showUserMenu)}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors hover:bg-gray-100/50 dark:hover:bg-gray-800/50"
>
<div
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
style="background-color: {$currentTheme.primary.default};"
>
{$user?.email?.charAt(0).toUpperCase()}
</div>
<div class="flex-1 overflow-hidden text-left">
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{$user?.email?.split('@')[0]}
</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">Account</p>
</div>
<svg
class="h-4 w-4 text-gray-400 transition-transform {showUserMenu ? 'rotate-180' : ''}"
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="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-2xl border border-gray-200/50 bg-white/95 shadow-lg backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-800/95"
>
<a
href="/app/profile"
onclick={() => (showUserMenu = false)}
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<svg class="h-4 w-4" 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>
<button
onclick={handleLogout}
class="flex w-full items-center gap-3 border-t border-gray-200 px-4 py-3 text-left text-sm text-red-600 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:text-red-400 dark:hover:bg-gray-700"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Abmelden
</button>
</div>
{/if}
</div>
</div>
</aside>
<!-- Mobile Header -->
<header class="fixed left-0 right-0 top-0 z-30 border-b border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900 lg:hidden">
<div class="flex h-16 items-center justify-between px-4">
<!-- Logo -->
<a href="/app/gallery" class="text-xl font-bold text-gray-900 dark:text-gray-100">
Picture
</a>
<!-- User Avatar -->
<button
onclick={() => (showUserMenu = !showUserMenu)}
class="flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold text-white"
style="background-color: {$currentTheme.primary.default};"
>
{$user?.email?.charAt(0).toUpperCase()}
</button>
</div>
<!-- Mobile User Menu -->
{#if showUserMenu}
<div class="border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
<nav class="flex flex-col">
{#each navItems as item}
{@const active = isActive(item.path)}
<a
href={item.path}
onclick={() => (showUserMenu = false)}
class="flex items-center gap-3 border-b border-gray-100 px-4 py-3 text-sm font-medium transition-colors last:border-b-0 {active
? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg
class="h-5 w-5 {active ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}"
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}
<a
href="/app/profile"
onclick={() => (showUserMenu = false)}
class="flex items-center gap-3 border-b border-gray-100 px-4 py-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg class="h-5 w-5 text-gray-400" 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>
<button
onclick={handleLogout}
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-red-600 transition-colors hover:bg-gray-50 dark:text-red-400 dark:hover:bg-gray-800"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Abmelden
</button>
</nav>
</div>
{/if}
</header>
<!-- Mobile Bottom Navigation -->
<nav
class="fixed bottom-0 left-0 right-0 z-30 border-t border-gray-200 bg-white pb-safe dark:border-gray-700 dark:bg-gray-900 lg:hidden"
>
<div class="grid grid-cols-4 gap-1 px-2 py-2">
{#each navItems as item}
{@const active = isActive(item.path)}
<a
href={item.path}
class="flex flex-col items-center gap-1 rounded-lg px-3 py-2 transition-colors {active
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
</svg>
<span class="text-xs font-medium">{item.label}</span>
</a>
{/each}
</div>
</nav>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { themeVariant, themeMode, setThemeVariant, setThemeMode, currentTheme } from '$lib/stores/theme';
import { themes, type ThemeVariant } from '@picture/design-tokens';
import type { ThemeMode } from '$lib/stores/theme';
interface ThemeOption {
value: ThemeVariant;
label: string;
icon: string;
}
interface ModeOption {
value: ThemeMode;
label: string;
icon: string;
}
const themeOptions: ThemeOption[] = [
{ value: 'default', label: 'Indigo', icon: '🔵' },
{ value: 'sunset', label: 'Sunset', icon: '🌅' },
{ value: 'ocean', label: 'Ocean', icon: '🌊' }
];
const modeOptions: ModeOption[] = [
{ value: 'system', label: 'System', icon: '💻' },
{ value: 'light', label: 'Hell', icon: '☀️' },
{ value: 'dark', label: 'Dunkel', icon: '🌙' }
];
</script>
<div class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-900">
<!-- Theme Variant Selection -->
<div class="mb-8">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">Theme</h3>
<div class="grid grid-cols-3 gap-3">
{#each themeOptions as option}
{@const isSelected = $themeVariant === option.value}
{@const themePreview = themes[option.value]}
<button
onclick={() => setThemeVariant(option.value)}
class="relative rounded-xl border-2 p-4 transition-all hover:scale-105 {isSelected
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950'
: 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}"
>
<!-- Icon -->
<div class="mb-2 text-4xl">{option.icon}</div>
<!-- Label -->
<div
class="mb-3 font-semibold {isSelected
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100'}"
>
{option.label}
</div>
<!-- Color Preview -->
<div class="flex justify-center gap-1">
<div
class="h-5 w-5 rounded-full"
style="background-color: {themePreview.colors.dark.primary.default}"
></div>
<div
class="h-5 w-5 rounded-full"
style="background-color: {themePreview.colors.dark.secondary.default}"
></div>
</div>
<!-- Checkmark -->
{#if isSelected}
<div class="absolute right-2 top-2">
<svg
class="h-6 w-6 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div>
{/if}
</button>
{/each}
</div>
</div>
<!-- Mode Selection -->
<div>
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">Modus</h3>
<div class="grid grid-cols-3 gap-3">
{#each modeOptions as option}
{@const isSelected = $themeMode === option.value}
<button
onclick={() => setThemeMode(option.value)}
class="flex flex-col items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all {isSelected
? 'border-blue-500 bg-blue-500 text-white'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'}"
>
<span class="text-2xl">{option.icon}</span>
<span class="text-sm font-medium">{option.label}</span>
</button>
{/each}
</div>
<!-- System Mode Info -->
{#if $themeMode === 'system'}
<div
class="mt-4 flex items-start gap-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950"
>
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-500 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p class="text-sm text-blue-700 dark:text-blue-300">
Das Theme folgt den Systemeinstellungen deines Geräts
</p>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { tags, selectedTags } from '$lib/stores/tags';
import type { Database } from '@picture/shared/types';
type Tag = Database['public']['Tables']['tags']['Row'];
function toggleTag(tagId: string) {
selectedTags.update((current) => {
const newTags = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
return newTags;
});
}
function isSelected(tagId: string): boolean {
return $selectedTags.includes(tagId);
}
function getTagColor(color: string | null): string {
if (!color) return 'bg-gray-200 dark:bg-gray-700';
return `bg-${color}-200 dark:bg-${color}-800`;
}
</script>
<div class="flex flex-wrap gap-2">
{#each $tags as tag (tag.id)}
{@const selected = isSelected(tag.id)}
<button
onclick={() => toggleTag(tag.id)}
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-all {selected
? 'bg-blue-600 text-white ring-2 ring-blue-600 ring-offset-2 dark:bg-blue-500 dark:ring-blue-500'
: 'bg-gray-100/80 text-gray-700 backdrop-blur-xl hover:bg-gray-200/80 dark:bg-gray-800/80 dark:text-gray-300 dark:hover:bg-gray-700/80'}"
>
{#if tag.color}
<span
class="h-2 w-2 rounded-full"
style="background-color: {tag.color};"
></span>
{/if}
<span>{tag.name}</span>
{#if selected}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
</button>
{/each}
{#if $tags.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">
Keine Tags vorhanden. Erstelle Tags in der Tag-Verwaltung.
</p>
{/if}
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
class?: string;
onclick?: () => void;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
type = 'button',
disabled = false,
loading = false,
class: className = '',
onclick,
children
}: Props = $props();
const baseStyles =
'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-600',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus-visible:ring-gray-400',
outline:
'border border-gray-300 bg-transparent hover:bg-gray-100 focus-visible:ring-gray-400',
ghost: 'hover:bg-gray-100 focus-visible:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600'
};
const sizes = {
sm: 'h-9 px-3 text-sm rounded-md',
md: 'h-10 px-4 py-2 rounded-md',
lg: 'h-11 px-8 text-lg rounded-md'
};
const buttonClass = `${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`;
</script>
<button {type} class={buttonClass} disabled={disabled || loading} {onclick}>
{#if loading}
<svg
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{/if}
{@render children()}
</button>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
padding?: boolean;
children: Snippet;
}
let { class: className = '', padding = true, children }: Props = $props();
const baseStyles = 'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900';
const paddingStyles = padding ? 'p-6' : '';
const cardClass = `${baseStyles} ${paddingStyles} ${className}`;
</script>
<div class={cardClass}>
{@render children()}
</div>

View file

@ -0,0 +1,323 @@
<script lang="ts">
import { onMount } from 'svelte';
import { contextMenu, hideContextMenu, showTagSubmenu, hideTagSubmenu } from '$lib/stores/contextMenu';
import { tags } from '$lib/stores/tags';
import { user } from '$lib/stores/auth';
import { addTagToImage, removeTagFromImage, getImageTags } from '$lib/api/tags';
import { archiveImage, unarchiveImage, deleteImage, toggleFavorite } from '$lib/api/images';
import { images } from '$lib/stores/images';
import { archivedImages } from '$lib/stores/archive';
import { showToast } from '$lib/stores/toast';
import type { Database } from '@picture/shared/types';
type Tag = Database['public']['Tables']['tags']['Row'];
let tagSubmenuElement = $state<HTMLElement | null>(null);
let imageTags = $state<Tag[]>([]);
// Check if current image is archived
const isArchived = $derived($contextMenu.image?.archived_at !== null && $contextMenu.image?.archived_at !== undefined);
// Check if current image is favorite
const isFavorite = $derived($contextMenu.image?.is_favorite === true);
// Check if current image belongs to current user
const isOwnImage = $derived($contextMenu.image?.user_id === $user?.id);
interface MenuItem {
label: string;
icon: string;
action: () => void;
submenu?: boolean;
divider?: boolean;
filled?: boolean; // For filled icons like favorite
}
$effect(() => {
if ($contextMenu.image) {
loadImageTags($contextMenu.image.id);
}
});
async function loadImageTags(imageId: string) {
try {
imageTags = await getImageTags(imageId);
} catch (error) {
console.error('Error loading image tags:', error);
}
}
function handleTagsMouseEnter(e: MouseEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
showTagSubmenu(rect.right, rect.top);
}
async function handleAddTag(tag: Tag) {
if (!$contextMenu.image) return;
try {
await addTagToImage($contextMenu.image.id, tag.id);
await loadImageTags($contextMenu.image.id);
showToast(`Tag "${tag.name}" hinzugefügt`, 'success');
} catch (error) {
console.error('Error adding tag:', error);
showToast('Fehler beim Hinzufügen des Tags', 'error');
}
}
async function handleRemoveTag(tag: Tag) {
if (!$contextMenu.image) return;
try {
await removeTagFromImage($contextMenu.image.id, tag.id);
await loadImageTags($contextMenu.image.id);
showToast(`Tag "${tag.name}" entfernt`, 'success');
} catch (error) {
console.error('Error removing tag:', error);
showToast('Fehler beim Entfernen des Tags', 'error');
}
}
function handleDownload() {
if (!$contextMenu.image?.public_url) return;
const link = document.createElement('a');
link.href = $contextMenu.image.public_url;
link.download = $contextMenu.image.filename || 'image.png';
link.click();
hideContextMenu();
showToast('Download gestartet', 'success');
}
function handleCopyLink() {
if (!$contextMenu.image?.public_url) return;
navigator.clipboard.writeText($contextMenu.image.public_url);
hideContextMenu();
showToast('Link kopiert', 'success');
}
async function handleDelete() {
if (!$contextMenu.image) return;
if (confirm('Möchten Sie dieses Bild wirklich löschen?')) {
try {
await deleteImage($contextMenu.image.id);
// Remove from store
images.update((current) => current.filter((img) => img.id !== $contextMenu.image?.id));
hideContextMenu();
showToast('Bild gelöscht', 'success');
} catch (error) {
console.error('Error deleting image:', error);
showToast('Fehler beim Löschen des Bildes', 'error');
}
}
}
async function handleArchive() {
if (!$contextMenu.image) return;
try {
if (isArchived) {
// Unarchive: Move back to gallery
await unarchiveImage($contextMenu.image.id);
// Remove from archive store
archivedImages.update((current) => current.filter((img) => img.id !== $contextMenu.image?.id));
hideContextMenu();
showToast('Bild wiederhergestellt', 'success');
} else {
// Archive: Move to archive
await archiveImage($contextMenu.image.id);
// Remove from gallery store
images.update((current) => current.filter((img) => img.id !== $contextMenu.image?.id));
hideContextMenu();
showToast('Bild archiviert', 'success');
}
} catch (error) {
console.error('Error archiving/unarchiving image:', error);
showToast('Fehler beim Archivieren des Bildes', 'error');
}
}
async function handleToggleFavorite() {
if (!$contextMenu.image) return;
try {
const newFavoriteStatus = !isFavorite;
await toggleFavorite($contextMenu.image.id, newFavoriteStatus);
// Update in all stores
images.update((current) =>
current.map((img) =>
img.id === $contextMenu.image?.id ? { ...img, is_favorite: newFavoriteStatus } : img
)
);
archivedImages.update((current) =>
current.map((img) =>
img.id === $contextMenu.image?.id ? { ...img, is_favorite: newFavoriteStatus } : img
)
);
hideContextMenu();
showToast(
newFavoriteStatus ? 'Zu Favoriten hinzugefügt' : 'Aus Favoriten entfernt',
'success'
);
} catch (error) {
console.error('Error toggling favorite:', error);
showToast('Fehler beim Aktualisieren der Favoriten', 'error');
}
}
const menuItems = $derived([
{
label: 'Herunterladen',
icon: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
action: handleDownload
},
{
label: 'Link kopieren',
icon: 'M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3',
action: handleCopyLink
},
{
label: isFavorite ? 'Aus Favoriten entfernen' : 'Zu 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',
action: handleToggleFavorite,
filled: isFavorite
},
{
label: 'Tags',
icon: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
action: () => {},
submenu: true,
divider: true
},
// Only show Archive/Restore for own images
...(isOwnImage ? [{
label: isArchived ? 'Wiederherstellen' : 'Archivieren',
icon: isArchived
? 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
action: handleArchive,
divider: true
}] : []),
// Only show Delete for own images
...(isOwnImage ? [{
label: 'Löschen',
icon: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
action: handleDelete
}] : [])
] as MenuItem[]);
function handleClick() {
hideContextMenu();
}
onMount(() => {
// Close menu on click outside
const handleClickOutside = () => {
hideContextMenu();
};
window.addEventListener('click', handleClickOutside);
return () => {
window.removeEventListener('click', handleClickOutside);
};
});
</script>
<svelte:window
on:contextmenu={(e) => {
if ($contextMenu.visible) {
e.preventDefault();
}
}}
/>
{#if $contextMenu.visible}
<div
class="fixed z-[60] min-w-[200px] rounded-2xl border border-gray-200/50 bg-white/95 py-2 shadow-2xl backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/95"
style="left: {$contextMenu.x}px; top: {$contextMenu.y}px;"
onclick={(e) => e.stopPropagation()}
role="menu"
>
{#each menuItems as item}
{#if item.divider}
<div class="my-2 border-t border-gray-200 dark:border-gray-700"></div>
{/if}
<button
onclick={() => {
if (!item.submenu) {
item.action();
}
}}
onmouseenter={item.submenu ? handleTagsMouseEnter : hideTagSubmenu}
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 {item.label === 'Löschen' ? 'text-red-600 dark:text-red-400' : ''}"
role="menuitem"
>
<svg class="h-4 w-4 flex-shrink-0" fill={item.filled ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
</svg>
<span class="flex-1">{item.label}</span>
{#if item.submenu}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{/if}
</button>
{/each}
</div>
<!-- Tag Submenu -->
{#if $contextMenu.showTagSubmenu}
<div
bind:this={tagSubmenuElement}
class="fixed z-[70] min-w-[220px] max-h-[400px] overflow-y-auto rounded-2xl border border-gray-200/50 bg-white/95 py-2 shadow-2xl backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/95"
style="left: {$contextMenu.submenuX}px; top: {$contextMenu.submenuY}px;"
onclick={(e) => e.stopPropagation()}
onmouseleave={hideTagSubmenu}
role="menu"
>
{#if $tags.length === 0}
<div class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
Keine Tags vorhanden
</div>
{:else}
<div class="px-3 pb-2 pt-1">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Tags hinzufügen/entfernen
</p>
</div>
{#each $tags as tag}
{@const hasTag = imageTags.some((t) => t.id === tag.id)}
<button
onclick={() => {
if (hasTag) {
handleRemoveTag(tag);
} else {
handleAddTag(tag);
}
hideTagSubmenu();
}}
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
role="menuitem"
>
<div
class="h-3 w-3 rounded-full flex-shrink-0"
style="background-color: {tag.color || '#6B7280'};"
></div>
<span class="flex-1 text-gray-700 dark:text-gray-300">{tag.name}</span>
{#if hasTag}
<svg class="h-4 w-4 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
{/if}

View file

@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 10 }: Props = $props();
</script>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each Array(count) as _, i}
<div class="aspect-square animate-pulse overflow-hidden rounded-lg bg-gray-200 dark:bg-gray-800">
<!-- Skeleton shimmer effect -->
<div class="h-full w-full bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-800 dark:via-gray-700 dark:to-gray-800"></div>
</div>
{/each}
</div>
<style>
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.animate-pulse {
animation: shimmer 2s infinite linear;
background-size: 1000px 100%;
}
</style>

View file

@ -0,0 +1,69 @@
<script lang="ts">
interface Props {
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
value?: string;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
required?: boolean;
class?: string;
id?: string;
name?: string;
autocomplete?: string;
onchange?: (e: Event) => void;
oninput?: (e: Event) => void;
}
let {
type = 'text',
value = $bindable(''),
placeholder = '',
label = '',
error = '',
disabled = false,
required = false,
class: className = '',
id = '',
name = '',
autocomplete = '',
onchange,
oninput
}: Props = $props();
const inputId = id || name || `input-${Math.random().toString(36).substr(2, 9)}`;
const baseStyles =
'flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
const errorStyles = error ? 'border-red-500 focus:ring-red-600' : '';
const inputClass = `${baseStyles} ${errorStyles} ${className}`;
</script>
{#if label}
<label for={inputId} class="mb-2 block text-sm font-medium text-gray-700">
{label}
{#if required}
<span class="text-red-500">*</span>
{/if}
</label>
{/if}
<input
{type}
bind:value
{placeholder}
{disabled}
{required}
{name}
{autocomplete}
id={inputId}
class={inputClass}
onchange={onchange}
oninput={oninput}
/>
{#if error}
<p class="mt-1 text-sm text-red-600">{error}</p>
{/if}

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { showKeyboardShortcuts } from '$lib/stores/ui';
interface Shortcut {
key: string;
description: string;
category: string;
}
const shortcuts: Shortcut[] = [
{ key: 'Tab', description: 'UI ein-/ausblenden', category: 'Allgemein' },
{ key: '?', description: 'Tastaturkürzel anzeigen', category: 'Allgemein' },
{ key: 'Escape', description: 'Modals/Overlays schließen', category: 'Allgemein' },
{ key: 'G', description: 'Zur Galerie', category: 'Navigation' },
{ key: 'E', description: 'Zu Entdecken', category: 'Navigation' },
{ key: 'N', description: 'Neue Generierung', category: 'Navigation' },
{ key: 'U', description: 'Zu Upload', category: 'Navigation' },
{ key: 'A', description: 'Zum Archiv', category: 'Navigation' },
{ key: '1', description: 'Listen-Ansicht', category: 'Ansicht' },
{ key: '2', description: 'Grid 3x3 Ansicht', category: 'Ansicht' },
{ key: '3', description: 'Grid 5x5 Ansicht', category: 'Ansicht' },
{ key: 'S', description: 'Sidebar öffnen/schließen', category: 'UI' }
];
const categories = [...new Set(shortcuts.map((s) => s.category))];
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
showKeyboardShortcuts.set(false);
}
}
</script>
<svelte:window on:keydown={handleKeyDown} />
{#if $showKeyboardShortcuts}
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onclick={() => showKeyboardShortcuts.set(false)}
role="presentation"
>
<div
class="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-3xl border border-gray-200/50 bg-white/95 p-8 shadow-2xl backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/95"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
>
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h2 id="shortcuts-title" class="text-2xl font-bold text-gray-900 dark:text-gray-100">
Tastaturkürzel
</h2>
<button
onclick={() => showKeyboardShortcuts.set(false)}
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100/80 text-gray-600 backdrop-blur-xl transition-all hover:bg-gray-200/80 dark:bg-gray-800/80 dark:text-gray-400 dark:hover:bg-gray-700/80"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Shortcuts by Category -->
<div class="space-y-6">
{#each categories as category}
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{category}
</h3>
<div class="space-y-2">
{#each shortcuts.filter((s) => s.category === category) as shortcut}
<div class="flex items-center justify-between rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50">
<span class="text-sm text-gray-700 dark:text-gray-300">{shortcut.description}</span>
<kbd
class="rounded-lg bg-white px-3 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-gray-300 dark:bg-gray-700 dark:text-gray-100 dark:ring-gray-600"
>
{shortcut.key}
</kbd>
</div>
{/each}
</div>
</div>
{/each}
</div>
<!-- Footer -->
<div class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-800 dark:text-blue-300">
💡 Tipp: Drücke <kbd
class="rounded bg-blue-200 px-2 py-0.5 font-mono text-xs font-semibold dark:bg-blue-800"
>?</kbd
>
um diese Hilfe jederzeit anzuzeigen
</p>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,80 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
interface Props {
open: boolean;
onClose: () => void;
size?: 'small' | 'medium' | 'large';
children: Snippet;
}
let { open, onClose, size = 'medium', children }: Props = $props();
const sizeClasses = {
small: 'max-w-md',
medium: 'max-w-2xl',
large: 'max-w-6xl'
};
let modalElement = $state<HTMLDivElement | null>(null);
onMount(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
onClose();
}
}
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
});
$effect(() => {
if (open && modalElement) {
// Lock body scroll when modal is open
document.body.style.overflow = 'hidden';
// Focus the modal
modalElement.focus();
} else {
// Restore body scroll
document.body.style.overflow = '';
}
});
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
{#if open}
<div
bind:this={modalElement}
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 dark:bg-black/70"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Enter' && handleBackdropClick(e)}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="relative max-h-[90vh] w-full {sizeClasses[size]} overflow-auto rounded-lg bg-white shadow-xl dark:bg-gray-900">
<button
onclick={onClose}
class="absolute right-4 top-4 z-10 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black/50 dark:bg-gray-800 dark:hover:bg-gray-700"
aria-label="Close"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{@render children()}
</div>
</div>
{/if}

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { toasts, dismissToast, type Toast } from '$lib/stores/toast';
import { fly, fade } from 'svelte/transition';
function getToastIcon(type: Toast['type']) {
switch (type) {
case 'success':
return {
path: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
color: 'text-green-500'
};
case 'error':
return {
path: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
color: 'text-red-500'
};
case 'warning':
return {
path: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
color: 'text-yellow-500'
};
case 'info':
default:
return {
path: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
color: 'text-blue-500'
};
}
}
function getToastBgColor(type: Toast['type']) {
switch (type) {
case 'success':
return 'bg-green-50 border-green-200';
case 'error':
return 'bg-red-50 border-red-200';
case 'warning':
return 'bg-yellow-50 border-yellow-200';
case 'info':
default:
return 'bg-blue-50 border-blue-200';
}
}
</script>
<div class="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
{#each $toasts as toast (toast.id)}
{@const icon = getToastIcon(toast.type)}
{@const bgColor = getToastBgColor(toast.type)}
<div
transition:fly={{ y: 50, duration: 300 }}
class="pointer-events-auto flex min-w-[320px] items-start gap-3 rounded-lg border p-4 shadow-lg {bgColor}"
role="alert"
>
<svg class="h-6 w-6 flex-shrink-0 {icon.color}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={icon.path} />
</svg>
<p class="flex-1 text-sm font-medium text-gray-900">{toast.message}</p>
<button
onclick={() => dismissToast(toast.id)}
class="flex-shrink-0 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { viewMode, cycleViewMode, type ViewMode } from '$lib/stores/view';
function getIcon(mode: ViewMode) {
switch (mode) {
case 'single':
return 'M4 6h16M4 12h16M4 18h16'; // List icon
case 'grid3':
return 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 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'; // 2x2 grid
case 'grid5':
return 'M4 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM10 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM16 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM4 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1v-2zM10 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2zM16 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2z'; // 3x2 grid
}
}
function getLabel(mode: ViewMode) {
switch (mode) {
case 'single':
return 'Liste';
case 'grid3':
return 'Mittel';
case 'grid5':
return 'Klein';
}
}
</script>
<button
onclick={cycleViewMode}
class="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
title="Ansicht wechseln ({getLabel($viewMode)})"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d={getIcon($viewMode)} />
</svg>
<span class="hidden sm:inline">{getLabel($viewMode)}</span>
</button>

View file

@ -0,0 +1,272 @@
<script lang="ts">
import { validateImage } from '$lib/api/upload';
import type { UploadProgress } from '$lib/api/upload';
interface Props {
onFilesSelected: (files: File[]) => void;
uploading?: boolean;
uploadProgress?: UploadProgress[];
}
let { onFilesSelected, uploading = false, uploadProgress = [] }: Props = $props();
let isDragging = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
let selectedFiles = $state<File[]>([]);
let previews = $state<{ file: File; url: string; error?: string }[]>([]);
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
isDragging = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const files = Array.from(e.dataTransfer?.files || []);
handleFiles(files);
}
function handleFileInputChange(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files || []);
handleFiles(files);
}
function handleFiles(files: File[]) {
// Filter only image files
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
// Validate each file
const validatedFiles = imageFiles.map((file) => {
const validation = validateImage(file);
const url = URL.createObjectURL(file);
return {
file,
url,
error: validation.valid ? undefined : validation.error
};
});
previews = validatedFiles;
selectedFiles = validatedFiles.filter((f) => !f.error).map((f) => f.file);
}
function removeFile(index: number) {
URL.revokeObjectURL(previews[index].url);
previews = previews.filter((_, i) => i !== index);
selectedFiles = selectedFiles.filter((_, i) => i !== index);
}
function handleUpload() {
if (selectedFiles.length > 0) {
onFilesSelected(selectedFiles);
}
}
function clearAll() {
previews.forEach((p) => URL.revokeObjectURL(p.url));
previews = [];
selectedFiles = [];
if (fileInput) fileInput.value = '';
}
function getProgressForFile(filename: string): UploadProgress | undefined {
return uploadProgress.find((p) => p.filename === filename);
}
// Cleanup on unmount
$effect(() => {
return () => {
previews.forEach((p) => URL.revokeObjectURL(p.url));
};
});
</script>
<div class="space-y-6">
<!-- Drop Zone -->
{#if !uploading && previews.length === 0}
<div
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => fileInput?.click()}
class="flex min-h-[400px] cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 transition-all {isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
: 'border-gray-300 bg-gray-50/50 hover:border-gray-400 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:border-gray-600'}"
role="button"
tabindex="0"
>
<svg
class="mb-4 h-16 w-16 {isDragging ? 'text-blue-500' : 'text-gray-400 dark:text-gray-600'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
{isDragging ? 'Loslassen zum Hochladen' : 'Bilder hochladen'}
</h3>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Ziehe deine Bilder hierher oder klicke zum Auswählen
</p>
<p class="text-xs text-gray-500 dark:text-gray-500">
JPG, PNG oder WebP • Max. 10MB pro Bild
</p>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/jpg,image/png,image/webp"
multiple
onchange={handleFileInputChange}
class="hidden"
/>
</div>
{/if}
<!-- Preview Grid -->
{#if previews.length > 0}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{previews.length} {previews.length === 1 ? 'Bild' : 'Bilder'} ausgewählt
</h3>
{#if !uploading}
<button
onclick={clearAll}
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
Alle entfernen
</button>
{/if}
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each previews as preview, index (preview.file.name)}
{@const progress = getProgressForFile(preview.file.name)}
<div
class="group relative overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<!-- Image Preview -->
<div class="aspect-square w-full">
<img
src={preview.url}
alt={preview.file.name}
class="h-full w-full object-cover"
/>
</div>
<!-- Overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"
>
<!-- Remove Button (only when not uploading) -->
{#if !uploading}
<button
onclick={() => removeFile(index)}
class="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-gray-900 opacity-0 transition-opacity hover:bg-white group-hover:opacity-100"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
<!-- File Info -->
<div class="absolute bottom-0 left-0 right-0 p-3">
<p class="truncate text-sm font-medium text-white">
{preview.file.name}
</p>
<p class="text-xs text-white/80">
{(preview.file.size / 1024 / 1024).toFixed(2)} MB
</p>
<!-- Error -->
{#if preview.error}
<div class="mt-2 rounded bg-red-500/90 px-2 py-1 text-xs text-white">
{preview.error}
</div>
{/if}
<!-- Progress -->
{#if progress}
<div class="mt-2 space-y-1">
<div class="flex items-center justify-between text-xs text-white">
<span>
{#if progress.status === 'uploading'}
Hochladen...
{:else if progress.status === 'success'}
✓ Fertig
{:else if progress.status === 'error'}
✗ Fehler
{:else}
Warten...
{/if}
</span>
{#if progress.status === 'uploading' || progress.status === 'success'}
<span>{Math.round(progress.progress)}%</span>
{/if}
</div>
{#if progress.status === 'uploading' || progress.status === 'success'}
<div class="h-1 w-full overflow-hidden rounded-full bg-white/20">
<div
class="h-full bg-white transition-all duration-300 {progress.status === 'success' ? 'bg-green-500' : ''}"
style="width: {progress.progress}%"
></div>
</div>
{/if}
{#if progress.error}
<p class="text-xs text-red-300">{progress.error}</p>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
<!-- Upload Button -->
{#if !uploading && selectedFiles.length > 0}
<div class="flex justify-center pt-4">
<button
onclick={handleUpload}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-base font-medium text-white transition-all hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{selectedFiles.length} {selectedFiles.length === 1 ? 'Bild' : 'Bilder'} hochladen
</button>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,49 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('picture_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale()
});
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('picture_locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,23 @@
{
"app_slider": {
"title": "Weitere Manacore Apps",
"memoro_desc": "KI-gestützte Sprachmemos",
"memoro_long_desc": "Verwandle deine Stimme in organisierte, umsetzbare Erkenntnisse mit KI-gestützter Transkription und Analyse. Perfekt zum Festhalten von Ideen unterwegs.",
"maerchenzauber_desc": "Magische Gute-Nacht-Geschichten",
"maerchenzauber_long_desc": "Erschaffe personalisierte Gute-Nacht-Geschichten für deine Kinder mit KI. Entfache die Fantasie und mache jede Nacht magisch mit einzigartigen Erzählungen.",
"manadeck_desc": "KI Lernkarten",
"manadeck_long_desc": "Erstelle und lerne mit smarten Lernkarten und KI-gestützter Wiederholung.",
"picture_desc": "KI Bildgenerierung",
"picture_long_desc": "Erstelle atemberaubende Bilder mit KI. Verwandle deine Ideen in visuelle Kunstwerke in Sekunden.",
"moodlit_desc": "Dein Stimmungsbegleiter",
"moodlit_long_desc": "Verfolge und verstehe deine Emotionen mit KI-gestützten Einblicken. Baue emotionales Bewusstsein auf und verbessere dein mentales Wohlbefinden.",
"manacore_desc": "KI-Produktivitätssuite",
"manacore_long_desc": "Die zentrale Anlaufstelle für alle Manacore-Apps. Verwalte deine Abonnements, synchronisiere Daten und greife auf leistungsstarke KI-Tools von einem Ort aus zu.",
"coming_soon": "Demnächst",
"download": "Download",
"status_published": "Veröffentlicht",
"status_beta": "Beta",
"status_development": "In Entwicklung",
"status_planning": "Geplant"
}
}

View file

@ -0,0 +1,23 @@
{
"app_slider": {
"title": "More Manacore Apps",
"memoro_desc": "AI-powered voice memos",
"memoro_long_desc": "Transform your voice into organized, actionable insights with AI-powered transcription and analysis. Perfect for capturing ideas on the go.",
"maerchenzauber_desc": "Magical bedtime stories",
"maerchenzauber_long_desc": "Create personalized bedtime stories for your children with AI. Spark imagination and make every night magical with unique narratives.",
"manadeck_desc": "AI flashcards",
"manadeck_long_desc": "Create and learn with smart flashcards and AI-powered spaced repetition.",
"picture_desc": "AI image generation",
"picture_long_desc": "Create stunning images with AI. Transform your ideas into visual artwork in seconds.",
"moodlit_desc": "Your mood companion",
"moodlit_long_desc": "Track and understand your emotions with AI-powered insights. Build emotional awareness and improve your mental wellbeing.",
"manacore_desc": "AI productivity suite",
"manacore_long_desc": "The central hub for all Manacore apps. Manage your subscriptions, sync data, and access powerful AI tools from one place.",
"coming_soon": "Coming soon",
"download": "Download",
"status_published": "Published",
"status_beta": "Beta",
"status_development": "In Development",
"status_planning": "Planned"
}
}

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,220 @@
/**
* Authentication service for Picture Web
* Uses Supabase auth with compatible interface for shared-auth-ui
*/
import { supabase } from '$lib/supabase';
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
}
export interface UserData {
id: string;
email: string;
role: string;
}
/**
* Authentication service compatible with @manacore/shared-auth-ui
*/
export const authService = {
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) {
// Handle specific error cases
if (error.message?.includes('Invalid login credentials')) {
return {
success: false,
error: 'INVALID_CREDENTIALS'
};
}
if (error.message?.includes('Email not confirmed')) {
return {
success: false,
error: 'EMAIL_NOT_VERIFIED'
};
}
return {
success: false,
error: error.message || 'Sign in failed'
};
}
if (data.session) {
return { success: true };
}
return {
success: false,
error: 'No session returned'
};
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in'
};
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
});
if (error) {
if (error.message?.includes('already registered')) {
return {
success: false,
error: 'This email is already in use'
};
}
return {
success: false,
error: error.message || 'Registration failed'
};
}
// Check if email confirmation is required
if (data.user && !data.session) {
return {
success: true,
needsVerification: true
};
}
return { success: true };
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during registration'
};
}
},
/**
* Sign in with Google (OAuth)
*/
async signInWithGoogle(): Promise<AuthResult> {
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/app/gallery`
}
});
if (error) {
return {
success: false,
error: error.message || 'Google Sign-In failed'
};
}
// OAuth redirects, so if we get here, it's working
return { success: true };
} catch (error) {
console.error('Error signing in with Google:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In'
};
}
},
/**
* Sign in with Apple (OAuth)
*/
async signInWithApple(): Promise<AuthResult> {
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo: `${window.location.origin}/app/gallery`
}
});
if (error) {
return {
success: false,
error: error.message || 'Apple Sign-In failed'
};
}
return { success: true };
} catch (error) {
console.error('Error signing in with Apple:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In'
};
}
},
/**
* Sign out
*/
async signOut(): Promise<void> {
try {
await supabase.auth.signOut();
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Forgot password
*/
async forgotPassword(email: string): Promise<AuthResult> {
try {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`
});
if (error) {
if (error.message?.includes('rate limit')) {
return {
success: false,
error: 'Too many password reset attempts. Please wait a few minutes before trying again.'
};
}
return {
success: false,
error: error.message || 'Password reset failed'
};
}
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset'
};
}
}
};

View file

@ -0,0 +1,9 @@
import { writable } from 'svelte/store';
import type { Database } from '@picture/shared/types';
type Image = Database['public']['Tables']['images']['Row'];
export const archivedImages = writable<Image[]>([]);
export const isLoadingArchive = writable(false);
export const hasMoreArchive = writable(true);
export const currentArchivePage = writable(1);

View file

@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
import type { User, Session } from '@supabase/supabase-js';
export const user = writable<User | null>(null);
export const session = writable<Session | null>(null);
export const loading = writable(true);

View file

@ -0,0 +1,79 @@
import { writable, derived } from 'svelte/store';
import type { Database } from '@picture/shared/types';
import type { BoardWithCount } from '$lib/api/boards';
type Board = Database['public']['Tables']['boards']['Row'];
// Current boards list
export const boards = writable<BoardWithCount[]>([]);
// Current board being edited
export const currentBoard = writable<Board | null>(null);
// Loading states
export const isLoadingBoards = writable(false);
export const isLoadingBoard = writable(false);
// Pagination
export const currentBoardsPage = writable(1);
export const hasBoardsMore = writable(true);
// Selected board (for actions like delete, duplicate)
export const selectedBoard = writable<Board | null>(null);
// Create board modal
export const showCreateBoardModal = writable(false);
// Share board modal
export const showShareBoardModal = writable(false);
export const shareBoardId = writable<string | null>(null);
// Board settings (for canvas)
export const boardSettings = derived(currentBoard, $currentBoard => ({
width: $currentBoard?.canvas_width || 2000,
height: $currentBoard?.canvas_height || 1500,
backgroundColor: $currentBoard?.background_color || '#ffffff'
}));
// Helper functions for board management
export function resetBoardsState() {
boards.set([]);
currentBoardsPage.set(1);
hasBoardsMore.set(true);
}
export function addBoard(board: BoardWithCount) {
boards.update(current => [board, ...current]);
}
export function updateBoardInList(boardId: string, updates: Partial<Board>) {
boards.update(current =>
current.map(board =>
board.id === boardId ? { ...board, ...updates } : board
)
);
}
export function removeBoardFromList(boardId: string) {
boards.update(current => current.filter(board => board.id !== boardId));
}
export function incrementBoardItemCount(boardId: string) {
boards.update(current =>
current.map(board =>
board.id === boardId
? { ...board, item_count: board.item_count + 1 }
: board
)
);
}
export function decrementBoardItemCount(boardId: string) {
boards.update(current =>
current.map(board =>
board.id === boardId
? { ...board, item_count: Math.max(0, board.item_count - 1) }
: board
)
);
}

View file

@ -0,0 +1,274 @@
import { writable, derived, get } from 'svelte/store';
import type { BoardItem, BoardImageItem, BoardTextItem } from '$lib/api/boardItems';
import { isImageItem, isTextItem } from '$lib/api/boardItems';
// Canvas items (images and texts on the board)
export const canvasItems = writable<BoardItem[]>([]);
// Selected items on canvas
export const selectedItemIds = writable<string[]>([]);
// Canvas view state
export const canvasZoom = writable(1);
export const canvasPan = writable({ x: 0, y: 0 });
// Canvas interaction mode
export type CanvasMode = 'select' | 'pan' | 'draw';
export const canvasMode = writable<CanvasMode>('select');
// Canvas tools
export const showGrid = writable(true);
export const snapToGrid = writable(false);
export const gridSize = writable(20);
// UI state
export const showPropertiesPanel = writable(false);
// Text editing state
export const editingTextId = writable<string | null>(null);
export const isEditingText = derived(editingTextId, $id => $id !== null);
// Loading state
export const isLoadingCanvasItems = writable(false);
// History for undo/redo
interface HistoryState {
items: BoardItem[];
timestamp: number;
}
export const canvasHistory = writable<HistoryState[]>([]);
export const canvasHistoryIndex = writable(-1);
// Derived stores
export const selectedItems = derived(
[canvasItems, selectedItemIds],
([$canvasItems, $selectedItemIds]) =>
$canvasItems.filter(item => $selectedItemIds.includes(item.id))
);
// Derived: Selected text items only
export const selectedTextItems = derived(selectedItems, $selectedItems =>
$selectedItems.filter(isTextItem)
);
// Derived: Selected image items only
export const selectedImageItems = derived(selectedItems, $selectedItems =>
$selectedItems.filter(isImageItem)
);
// Derived: Check if selection has mixed types
export const hasMixedSelection = derived(
[selectedTextItems, selectedImageItems],
([$texts, $images]) => $texts.length > 0 && $images.length > 0
);
export const hasSelection = derived(
selectedItemIds,
$selectedItemIds => $selectedItemIds.length > 0
);
export const canUndo = derived(
canvasHistoryIndex,
$canvasHistoryIndex => $canvasHistoryIndex > 0
);
export const canRedo = derived(
[canvasHistory, canvasHistoryIndex],
([$canvasHistory, $canvasHistoryIndex]) =>
$canvasHistoryIndex < $canvasHistory.length - 1
);
// Helper functions
export function addCanvasItem(item: BoardItem) {
canvasItems.update(items => [...items, item]);
saveToHistory();
}
export function updateCanvasItem(id: string, updates: Partial<BoardItem>) {
canvasItems.update(items =>
items.map(item => (item.id === id ? { ...item, ...updates } : item))
);
saveToHistory();
}
// Text-specific helpers
export function startEditingText(id: string) {
editingTextId.set(id);
}
export function stopEditingText() {
editingTextId.set(null);
}
export function removeCanvasItem(id: string) {
canvasItems.update(items => items.filter(item => item.id !== id));
selectedItemIds.update(ids => ids.filter(itemId => itemId !== id));
saveToHistory();
}
export function removeSelectedItems() {
const ids = get(selectedItemIds);
canvasItems.update(items => items.filter(item => !ids.includes(item.id)));
selectedItemIds.set([]);
saveToHistory();
}
export function selectItem(id: string, multi = false) {
console.log('[Store] selectItem called:', id, 'multi:', multi);
if (multi) {
selectedItemIds.update(ids => {
const newIds = ids.includes(id)
? ids.filter(itemId => itemId !== id)
: [...ids, id];
console.log('[Store] Updated selection (multi):', newIds);
return newIds;
});
} else {
console.log('[Store] Setting single selection:', [id]);
selectedItemIds.set([id]);
}
}
export function selectAll() {
selectedItemIds.set(get(canvasItems).map(item => item.id));
}
export function deselectAll() {
selectedItemIds.set([]);
}
export function bringToFront(id: string) {
const items = get(canvasItems);
const maxZIndex = Math.max(...items.map(item => item.z_index));
updateCanvasItem(id, { z_index: maxZIndex + 1 });
}
export function sendToBack(id: string) {
const items = get(canvasItems);
const minZIndex = Math.min(...items.map(item => item.z_index));
updateCanvasItem(id, { z_index: minZIndex - 1 });
}
export function moveForward(id: string) {
const items = get(canvasItems);
const item = items.find(i => i.id === id);
if (!item) return;
const itemsAbove = items.filter(i => i.z_index > item.z_index);
if (itemsAbove.length === 0) return;
const nextZIndex = Math.min(...itemsAbove.map(i => i.z_index));
updateCanvasItem(id, { z_index: nextZIndex + 0.5 });
}
export function moveBackward(id: string) {
const items = get(canvasItems);
const item = items.find(i => i.id === id);
if (!item) return;
const itemsBelow = items.filter(i => i.z_index < item.z_index);
if (itemsBelow.length === 0) return;
const prevZIndex = Math.max(...itemsBelow.map(i => i.z_index));
updateCanvasItem(id, { z_index: prevZIndex - 0.5 });
}
// Zoom functions
export function zoomIn() {
canvasZoom.update(z => Math.min(z * 1.2, 5));
}
export function zoomOut() {
canvasZoom.update(z => Math.max(z / 1.2, 0.1));
}
export function zoomToFit(containerWidth: number, containerHeight: number, boardWidth: number, boardHeight: number) {
const scaleX = containerWidth / boardWidth;
const scaleY = containerHeight / boardHeight;
const scale = Math.min(scaleX, scaleY) * 0.9; // 90% to add padding
canvasZoom.set(scale);
canvasPan.set({ x: 0, y: 0 });
}
export function resetZoom() {
canvasZoom.set(1);
canvasPan.set({ x: 0, y: 0 });
}
// History management
export function saveToHistory() {
const items = get(canvasItems);
const history = get(canvasHistory);
const index = get(canvasHistoryIndex);
// Remove any history after current index
const newHistory = history.slice(0, index + 1);
// Add current state
newHistory.push({
items: JSON.parse(JSON.stringify(items)), // Deep clone
timestamp: Date.now()
});
// Limit history to 50 states
if (newHistory.length > 50) {
newHistory.shift();
}
canvasHistory.set(newHistory);
canvasHistoryIndex.set(newHistory.length - 1);
}
export function undo() {
const index = get(canvasHistoryIndex);
if (index <= 0) return;
const history = get(canvasHistory);
const prevState = history[index - 1];
canvasItems.set(JSON.parse(JSON.stringify(prevState.items)));
canvasHistoryIndex.set(index - 1);
}
export function redo() {
const index = get(canvasHistoryIndex);
const history = get(canvasHistory);
if (index >= history.length - 1) return;
const nextState = history[index + 1];
canvasItems.set(JSON.parse(JSON.stringify(nextState.items)));
canvasHistoryIndex.set(index + 1);
}
export function clearHistory() {
canvasHistory.set([]);
canvasHistoryIndex.set(-1);
}
// Reset canvas state
export function resetCanvasState() {
canvasItems.set([]);
selectedItemIds.set([]);
canvasZoom.set(1);
canvasPan.set({ x: 0, y: 0 });
clearHistory();
}
// Grid snapping helper
export function snapToGridPoint(value: number, gridSize: number): number {
return Math.round(value / gridSize) * gridSize;
}
export function snapPositionToGrid(x: number, y: number): { x: number; y: number } {
const size = get(gridSize);
const snap = get(snapToGrid);
if (!snap) return { x, y };
return {
x: snapToGridPoint(x, size),
y: snapToGridPoint(y, size)
};
}

View file

@ -0,0 +1,58 @@
import { writable } from 'svelte/store';
import type { Database } from '@picture/shared/types';
type Image = Database['public']['Tables']['images']['Row'];
interface ContextMenuState {
visible: boolean;
x: number;
y: number;
image: Image | null;
showTagSubmenu: boolean;
submenuX: number;
submenuY: number;
}
const initialState: ContextMenuState = {
visible: false,
x: 0,
y: 0,
image: null,
showTagSubmenu: false,
submenuX: 0,
submenuY: 0
};
export const contextMenu = writable<ContextMenuState>(initialState);
export function showContextMenu(x: number, y: number, image: Image) {
contextMenu.set({
visible: true,
x,
y,
image,
showTagSubmenu: false,
submenuX: 0,
submenuY: 0
});
}
export function hideContextMenu() {
contextMenu.set(initialState);
}
export function showTagSubmenu(x: number, y: number) {
contextMenu.update((state) => ({
...state,
showTagSubmenu: true,
submenuX: x,
submenuY: y
}));
}
export function hideTagSubmenu() {
contextMenu.update((state) => ({
...state,
showTagSubmenu: false
}));
}

View file

@ -0,0 +1,12 @@
import { writable } from 'svelte/store';
import type { Database } from '@picture/shared/types';
type Image = Database['public']['Tables']['images']['Row'];
export const exploreImages = writable<Image[]>([]);
export const isLoadingExplore = writable(false);
export const hasMoreExplore = writable(true);
export const currentExplorePage = writable(1);
export const exploreSortBy = writable<'recent' | 'popular' | 'trending'>('recent');
export const exploreSearchQuery = writable('');
export const showExploreFavoritesOnly = writable(false);

View file

@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
export const isGenerating = writable(false);
export const generationProgress = writable<string>('');
export const generationError = writable<string>('');

View file

@ -0,0 +1,11 @@
import { writable } from 'svelte/store';
import type { Database } from '@picture/shared/types';
type Image = Database['public']['Tables']['images']['Row'];
export const images = writable<Image[]>([]);
export const selectedImage = writable<Image | null>(null);
export const isLoading = writable(false);
export const hasMore = writable(true);
export const currentPage = writable(1);
export const showFavoritesOnly = writable(false);

View file

@ -0,0 +1,8 @@
import { writable } from 'svelte/store';
import type { Database } from '@picture/shared/types';
type Model = Database['public']['Tables']['models']['Row'];
export const models = writable<Model[]>([]);
export const selectedModel = writable<Model | null>(null);
export const isLoadingModels = writable(false);

View file

@ -0,0 +1,29 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const SIDEBAR_KEY = 'picture_sidebar_collapsed';
function loadInitialState(): boolean {
if (!browser) return false;
const saved = localStorage.getItem(SIDEBAR_KEY);
return saved === 'true';
}
export const isSidebarCollapsed = writable<boolean>(loadInitialState());
export function toggleSidebar() {
isSidebarCollapsed.update((collapsed) => {
const newState = !collapsed;
if (browser) {
localStorage.setItem(SIDEBAR_KEY, String(newState));
}
return newState;
});
}
export function setSidebarCollapsed(collapsed: boolean) {
isSidebarCollapsed.set(collapsed);
if (browser) {
localStorage.setItem(SIDEBAR_KEY, String(collapsed));
}
}

View file

@ -0,0 +1,8 @@
import { writable } from 'svelte/store';
import type { Database } from '@picture/shared/types';
type Tag = Database['public']['Tables']['tags']['Row'];
export const tags = writable<Tag[]>([]);
export const selectedTags = writable<string[]>([]);
export const isLoadingTags = writable<boolean>(false);

View file

@ -0,0 +1,125 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
import { themes, type ThemeVariant } from '@picture/design-tokens';
export type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeState {
variant: ThemeVariant;
mode: ThemeMode;
}
const THEME_VARIANT_KEY = 'picture_theme_variant';
const THEME_MODE_KEY = 'picture_theme_mode';
// Load initial values from localStorage
function loadInitialTheme(): ThemeState {
if (!browser) {
return { variant: 'default', mode: 'system' };
}
const savedVariant = localStorage.getItem(THEME_VARIANT_KEY) as ThemeVariant | null;
const savedMode = localStorage.getItem(THEME_MODE_KEY) as ThemeMode | null;
return {
variant: savedVariant || 'default',
mode: savedMode || 'system'
};
}
// Create stores with initial values
const initialTheme = loadInitialTheme();
export const themeVariant = writable<ThemeVariant>(initialTheme.variant);
export const themeMode = writable<ThemeMode>(initialTheme.mode);
// Derive the actual mode (resolve 'system' to 'light' or 'dark')
export const actualMode = derived(themeMode, ($mode) => {
if ($mode === 'system' && browser) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return $mode === 'system' ? 'dark' : $mode;
});
// Derive the current theme object
export const currentTheme = derived(
[themeVariant, actualMode],
([$variant, $actualMode]) => {
const theme = themes[$variant];
return theme.colors[$actualMode];
}
);
// Actions
export function setThemeVariant(variant: ThemeVariant) {
themeVariant.set(variant);
if (browser) {
localStorage.setItem(THEME_VARIANT_KEY, variant);
}
}
export function setThemeMode(mode: ThemeMode) {
themeMode.set(mode);
if (browser) {
localStorage.setItem(THEME_MODE_KEY, mode);
}
}
export function toggleThemeMode() {
themeMode.update((current) => {
const newMode = current === 'dark' ? 'light' : 'dark';
if (browser) {
localStorage.setItem(THEME_MODE_KEY, newMode);
}
return newMode;
});
}
// Listen to system theme changes and apply theme to DOM
if (browser) {
// Listen to system color scheme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
// Force re-evaluation of actualMode when system preference changes
themeMode.update((mode) => mode);
});
// Apply CSS custom properties and background colors
currentTheme.subscribe((theme) => {
const root = document.documentElement;
// Primary colors
root.style.setProperty('--color-primary', theme.primary.default);
root.style.setProperty('--color-primary-hover', theme.primary.hover);
root.style.setProperty('--color-primary-active', theme.primary.active);
// Background colors
root.style.setProperty('--color-background', theme.background);
root.style.setProperty('--color-surface', theme.surface);
root.style.setProperty('--color-elevated', theme.elevated);
// Text colors
root.style.setProperty('--color-text-primary', theme.text.primary);
root.style.setProperty('--color-text-secondary', theme.text.secondary);
root.style.setProperty('--color-text-tertiary', theme.text.tertiary);
// Border colors
root.style.setProperty('--color-border', theme.border);
root.style.setProperty('--color-divider', theme.divider);
// Status colors
root.style.setProperty('--color-success', theme.success);
root.style.setProperty('--color-error', theme.error);
root.style.setProperty('--color-warning', theme.warning);
root.style.setProperty('--color-info', theme.info);
// Apply background color to body
document.body.style.backgroundColor = theme.background;
document.body.style.color = theme.text.primary;
});
// Apply dark/light mode class to document element
actualMode.subscribe((mode) => {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(mode);
});
}

View file

@ -0,0 +1,37 @@
import { writable } from 'svelte/store';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
export const toasts = writable<Toast[]>([]);
let toastId = 0;
export function showToast(message: string, type: ToastType = 'info', duration = 5000) {
const id = `toast-${toastId++}`;
const toast: Toast = { id, message, type, duration };
toasts.update((current) => [...current, toast]);
if (duration > 0) {
setTimeout(() => {
dismissToast(id);
}, duration);
}
return id;
}
export function dismissToast(id: string) {
toasts.update((current) => current.filter((toast) => toast.id !== id));
}
export function clearToasts() {
toasts.set([]);
}

View file

@ -0,0 +1,24 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const UI_VISIBLE_KEY = 'picture_ui_visible';
function loadInitialState(): boolean {
if (!browser) return true;
const saved = localStorage.getItem(UI_VISIBLE_KEY);
return saved !== 'false'; // Default to true
}
export const isUIVisible = writable<boolean>(loadInitialState());
export function toggleUI() {
isUIVisible.update((visible) => {
const newState = !visible;
if (browser) {
localStorage.setItem(UI_VISIBLE_KEY, String(newState));
}
return newState;
});
}
export const showKeyboardShortcuts = writable<boolean>(false);

View file

@ -0,0 +1,35 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export type ViewMode = 'single' | 'grid3' | 'grid5';
const VIEW_MODE_KEY = 'picture_view_mode';
function loadInitialViewMode(): ViewMode {
if (!browser) {
return 'grid3';
}
const saved = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null;
return saved || 'grid3';
}
export const viewMode = writable<ViewMode>(loadInitialViewMode());
export function setViewMode(mode: ViewMode) {
viewMode.set(mode);
if (browser) {
localStorage.setItem(VIEW_MODE_KEY, mode);
}
}
export function cycleViewMode() {
viewMode.update((current) => {
const modes: ViewMode[] = ['single', 'grid3', 'grid5'];
const currentIndex = modes.indexOf(current);
const nextMode = modes[(currentIndex + 1) % modes.length];
if (browser) {
localStorage.setItem(VIEW_MODE_KEY, nextMode);
}
return nextMode;
});
}

View file

@ -0,0 +1,15 @@
import { createClient } from '@supabase/supabase-js'
import type { Database } from '@picture/shared/types'
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
export const supabase = createClient<Database>(
PUBLIC_SUPABASE_URL,
PUBLIC_SUPABASE_ANON_KEY,
{
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
}
)

View file

@ -0,0 +1,79 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { supabase } from '$lib/supabase';
import { user, session, loading } from '$lib/stores/auth';
import Toast from '$lib/components/ui/Toast.svelte';
import { onMount } from 'svelte';
import { initPostHog, analytics } from '$lib/analytics/posthog';
// Import theme stores to initialize them
import '$lib/stores/theme';
// Initialize i18n
import '$lib/i18n';
let { children, data } = $props();
onMount(() => {
// Initialize PostHog
initPostHog();
// Get initial session
supabase.auth.getSession().then(({ data: { session: initialSession } }) => {
session.set(initialSession);
user.set(initialSession?.user ?? null);
loading.set(false);
// Identify user in PostHog if logged in
if (initialSession?.user) {
analytics.identify(initialSession.user.id, {
email: initialSession.user.email,
created_at: initialSession.user.created_at
});
}
});
// Listen for auth changes
const {
data: { subscription }
} = supabase.auth.onAuthStateChange((_event, newSession) => {
session.set(newSession);
user.set(newSession?.user ?? null);
// Update PostHog identity on auth changes
if (newSession?.user) {
analytics.identify(newSession.user.id, {
email: newSession.user.email,
created_at: newSession.user.created_at
});
} else {
// Reset PostHog on logout
analytics.reset();
}
});
return () => {
subscription.unsubscribe();
};
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<!-- Umami Analytics -->
{#if import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && import.meta.env.PUBLIC_UMAMI_URL}
<script
defer
src={`${import.meta.env.PUBLIC_UMAMI_URL}/script.js`}
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
data-do-not-track="true"
></script>
{/if}
</svelte:head>
{@render children?.()}
<!-- Global Toast Notifications -->
<Toast />

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
const unsubscribe = user.subscribe((currentUser) => {
if (currentUser) {
goto('/app/gallery');
} else {
goto('/auth/login');
}
});
return unsubscribe;
});
</script>
<svelte:head>
<title>Picture - AI Image Generation</title>
</svelte:head>
<!-- Redirect happens in onMount -->

View file

@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
// This will be populated by hooks.server.ts
// For now, we'll use a simple client-side check
// TODO: Implement proper SSR auth in hooks.server.ts
return {};
};

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { user, loading } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import KeyboardShortcutsModal from '$lib/components/ui/KeyboardShortcutsModal.svelte';
import { currentTheme } from '$lib/stores/theme';
import { isSidebarCollapsed, toggleSidebar } from '$lib/stores/sidebar';
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { viewMode } from '$lib/stores/view';
import { page } from '$app/stores';
let { children } = $props();
// Client-side auth check
onMount(() => {
const unsubscribe = user.subscribe((currentUser) => {
if (!$loading && !currentUser) {
goto('/auth/login');
}
});
return unsubscribe;
});
// Global keyboard shortcuts
function handleKeyDown(e: KeyboardEvent) {
// Ignore if user is typing in an input/textarea
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
switch (e.key.toLowerCase()) {
case 'tab':
e.preventDefault();
toggleUI();
break;
case '?':
e.preventDefault();
showKeyboardShortcuts.set(true);
break;
case 'escape':
showKeyboardShortcuts.set(false);
break;
case 'g':
e.preventDefault();
goto('/app/gallery');
break;
case 'm':
e.preventDefault();
goto('/app/board');
break;
case 'e':
e.preventDefault();
goto('/app/explore');
break;
case 'n':
e.preventDefault();
goto('/app/generate');
break;
case 'u':
e.preventDefault();
goto('/app/upload');
break;
case 'a':
e.preventDefault();
goto('/app/archive');
break;
case '1':
e.preventDefault();
viewMode.set('single');
break;
case '2':
e.preventDefault();
viewMode.set('grid3');
break;
case '3':
e.preventDefault();
viewMode.set('grid5');
break;
case 's':
e.preventDefault();
toggleSidebar();
break;
}
}
</script>
<svelte:window on:keydown={handleKeyDown} />
{#if $loading}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<p class="text-gray-600">Loading...</p>
</div>
</div>
{:else if $user}
<div class="min-h-screen" style="background-color: {$currentTheme.background};">
<!-- Sidebar (conditionally visible) -->
{#if $isUIVisible}
<Sidebar />
{/if}
<!-- Main Content Area -->
<main
class="transition-all duration-300 {$isSidebarCollapsed || !$isUIVisible
? 'lg:pl-0'
: 'lg:pl-[17rem]'}"
>
<!-- Desktop: Left padding when sidebar is open -->
<!-- Mobile: Top padding for header + Bottom padding for nav -->
<div class="min-h-screen pb-20 pt-16 lg:pb-0 lg:pt-0">
{@render children?.()}
</div>
</main>
<!-- Keyboard Shortcuts Modal -->
<KeyboardShortcutsModal />
</div>
{/if}

View file

@ -0,0 +1,154 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import {
archivedImages,
isLoadingArchive,
hasMoreArchive,
currentArchivePage
} from '$lib/stores/archive';
import { getImages } from '$lib/api/images';
import ArchivedImageCard from '$lib/components/archive/ArchivedImageCard.svelte';
import ArchivedImageModal from '$lib/components/archive/ArchivedImageModal.svelte';
import ImageSkeleton from '$lib/components/ui/ImageSkeleton.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import { onMount } from 'svelte';
import type { Database } from '@picture/shared/types';
type Image = Database['public']['Tables']['images']['Row'];
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
let selectedImage = $state<Image | null>(null);
onMount(() => {
loadInitialImages();
// Setup Intersection Observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
if (
entries[0].isIntersecting &&
$hasMoreArchive &&
!$isLoadingArchive &&
!loadingMore
) {
loadMoreImages();
}
},
{
threshold: 0.1,
rootMargin: '100px' // Load before reaching the trigger
}
);
if (loadMoreTrigger) {
observer.observe(loadMoreTrigger);
}
return () => {
if (observer) observer.disconnect();
};
});
async function loadInitialImages() {
if (!$user) return;
isLoadingArchive.set(true);
try {
const data = await getImages({ userId: $user.id, page: 1, archived: true });
archivedImages.set(data);
currentArchivePage.set(1);
hasMoreArchive.set(data.length === 20);
} catch (error) {
console.error('Error loading archived images:', error);
} finally {
isLoadingArchive.set(false);
}
}
async function loadMoreImages() {
if (!$user || !$hasMoreArchive || $isLoadingArchive || loadingMore) return;
loadingMore = true;
const nextPage = $currentArchivePage + 1;
try {
const newImages = await getImages({ userId: $user.id, page: nextPage, archived: true });
if (newImages.length > 0) {
archivedImages.update((current) => [...current, ...newImages]);
currentArchivePage.set(nextPage);
hasMoreArchive.set(newImages.length === 20);
} else {
hasMoreArchive.set(false);
}
} catch (error) {
console.error('Error loading more archived images:', error);
} finally {
loadingMore = false;
}
}
function handleImageClick(image: Image) {
selectedImage = image;
}
</script>
<svelte:head>
<title>Archive - Picture</title>
</svelte:head>
{#if $isLoadingArchive}
<div class="px-4 py-8">
<ImageSkeleton count={20} />
</div>
{:else if $archivedImages.length === 0}
<div class="flex min-h-[400px] items-center justify-center px-4 py-8">
<div class="text-center">
<svg
class="mx-auto h-16 w-16 text-gray-400 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">Kein Archiv</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Archiviere Bilder aus deiner Galerie, um sie organisiert zu halten ohne sie zu löschen
</p>
</div>
</div>
{:else}
<div class="px-4 py-8 pb-32">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each $archivedImages as image (image.id)}
<ArchivedImageCard {image} onclick={() => handleImageClick(image)} />
{/each}
</div>
<!-- Infinite Scroll Trigger -->
{#if $hasMoreArchive}
<div bind:this={loadMoreTrigger} class="mt-8 flex justify-center">
{#if loadingMore}
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">Scroll to load more</p>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Image Detail Modal -->
<ArchivedImageModal image={selectedImage} onClose={() => (selectedImage = null)} />
<!-- Context Menu -->
<ContextMenu />

View file

@ -0,0 +1,388 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores/auth';
import {
boards,
isLoadingBoards,
currentBoardsPage,
hasBoardsMore,
showCreateBoardModal,
selectedBoard,
resetBoardsState,
addBoard,
removeBoardFromList
} from '$lib/stores/boards';
import { getBoards, deleteBoard, duplicateBoard } from '$lib/api/boards';
import Button from '$lib/components/ui/Button.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import { showToast } from '$lib/stores/toast';
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
// Create board modal state
let boardName = $state('');
let boardDescription = $state('');
let isCreating = $state(false);
// Delete confirmation modal
let showDeleteModal = $state(false);
let deletingBoard = $state<string | null>(null);
onMount(async () => {
resetBoardsState();
await loadInitialBoards();
// Setup Intersection Observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && $hasBoardsMore && !$isLoadingBoards && !loadingMore) {
loadMoreBoards();
}
},
{
threshold: 0.1,
rootMargin: '100px'
}
);
if (loadMoreTrigger) {
observer.observe(loadMoreTrigger);
}
return () => {
if (observer) observer.disconnect();
};
});
async function loadInitialBoards() {
if (!$user) return;
isLoadingBoards.set(true);
try {
const data = await getBoards({ userId: $user.id, page: 1 });
boards.set(data);
currentBoardsPage.set(1);
hasBoardsMore.set(data.length === 20);
} catch (error) {
console.error('Error loading boards:', error);
showToast('Fehler beim Laden der Boards', 'error');
} finally {
isLoadingBoards.set(false);
}
}
async function loadMoreBoards() {
if (!$user || !$hasBoardsMore || $isLoadingBoards || loadingMore) return;
loadingMore = true;
const nextPage = $currentBoardsPage + 1;
try {
const newBoards = await getBoards({ userId: $user.id, page: nextPage });
if (newBoards.length > 0) {
boards.update((current) => [...current, ...newBoards]);
currentBoardsPage.set(nextPage);
hasBoardsMore.set(newBoards.length === 20);
} else {
hasBoardsMore.set(false);
}
} catch (error) {
console.error('Error loading more boards:', error);
} finally {
loadingMore = false;
}
}
async function handleCreateBoard() {
if (!$user || !boardName.trim()) return;
isCreating = true;
try {
const { createBoard } = await import('$lib/api/boards');
const newBoard = await createBoard({
user_id: $user.id,
name: boardName,
description: boardDescription || null
});
addBoard({ ...newBoard, item_count: 0 });
showCreateBoardModal.set(false);
boardName = '';
boardDescription = '';
showToast('Board erstellt', 'success');
} catch (error) {
console.error('Error creating board:', error);
showToast('Fehler beim Erstellen', 'error');
} finally {
isCreating = false;
}
}
async function handleDeleteBoard() {
if (!deletingBoard) return;
try {
await deleteBoard(deletingBoard);
removeBoardFromList(deletingBoard);
showDeleteModal = false;
deletingBoard = null;
showToast('Board gelöscht', 'success');
} catch (error) {
console.error('Error deleting board:', error);
showToast('Fehler beim Löschen', 'error');
}
}
async function handleDuplicateBoard(boardId: string) {
if (!$user) return;
try {
const newBoard = await duplicateBoard(boardId, $user.id);
addBoard({ ...newBoard, item_count: 0 });
showToast('Board dupliziert', 'success');
} catch (error) {
console.error('Error duplicating board:', error);
showToast('Fehler beim Duplizieren', 'error');
}
}
function openBoard(boardId: string) {
goto(`/app/board/${boardId}`);
}
function confirmDelete(boardId: string) {
deletingBoard = boardId;
showDeleteModal = true;
}
</script>
<svelte:head>
<title>Moodboards - Picture</title>
</svelte:head>
<div class="min-h-screen px-4 py-8">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Moodboards</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Erstelle und organisiere deine Bilder auf einem Canvas
</p>
</div>
<Button onclick={() => showCreateBoardModal.set(true)}>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neues Board
</Button>
</div>
<!-- Loading State -->
{#if $isLoadingBoards}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each Array(8) as _}
<div class="animate-pulse">
<div class="aspect-[4/3] rounded-lg bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-3 h-6 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-2 h-4 w-2/3 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
{/each}
</div>
{:else if $boards.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-20">
<svg
class="h-24 w-24 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="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 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-3zM14 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1v-3z"
/>
</svg>
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-gray-100">
Keine Boards vorhanden
</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Erstelle dein erstes Moodboard und organisiere deine Bilder
</p>
<Button class="mt-6" onclick={() => showCreateBoardModal.set(true)}>
Erstes Board erstellen
</Button>
</div>
{:else}
<!-- Boards Grid -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each $boards as board (board.id)}
<div
class="group relative overflow-hidden rounded-lg border border-gray-200 bg-white transition-all hover:shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<!-- Thumbnail -->
<button
onclick={() => openBoard(board.id)}
class="block w-full overflow-hidden bg-gray-100 dark:bg-gray-700"
style="aspect-ratio: 4/3; background-color: {board.background_color || '#ffffff'}"
>
{#if board.thumbnail_url}
<img
src={board.thumbnail_url}
alt={board.name}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full items-center justify-center">
<svg
class="h-16 w-16 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
{/if}
</button>
<!-- Info -->
<div class="p-4">
<button onclick={() => openBoard(board.id)} class="w-full text-left">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
{board.name}
</h3>
{#if board.description}
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{board.description}
</p>
{/if}
</button>
<div class="mt-3 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>{board.item_count} {board.item_count === 1 ? 'Bild' : 'Bilder'}</span>
<span>{new Date(board.updated_at).toLocaleDateString('de-DE')}</span>
</div>
<!-- Actions -->
<div class="mt-3 flex gap-2">
<Button
size="sm"
variant="outline"
class="flex-1"
onclick={() => handleDuplicateBoard(board.id)}
>
Duplizieren
</Button>
<Button
size="sm"
variant="danger"
onclick={() => confirmDelete(board.id)}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</Button>
</div>
</div>
</div>
{/each}
</div>
<!-- Infinite Scroll Trigger -->
{#if $hasBoardsMore}
<div bind:this={loadMoreTrigger} class="mt-8 flex justify-center">
{#if loadingMore}
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">Scroll to load more</p>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Create Board Modal -->
<Modal open={$showCreateBoardModal} onClose={() => showCreateBoardModal.set(false)}>
<div class="p-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Neues Board erstellen</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreateBoard();
}}
class="mt-6 space-y-4"
>
<div>
<label for="board-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</label>
<input
id="board-name"
type="text"
bind:value={boardName}
placeholder="Mein Moodboard"
required
class="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label for="board-description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Beschreibung (optional)
</label>
<textarea
id="board-description"
bind:value={boardDescription}
placeholder="Beschreibe dein Board..."
rows="3"
class="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
></textarea>
</div>
<div class="flex gap-3 pt-4">
<Button type="button" variant="outline" class="flex-1" onclick={() => showCreateBoardModal.set(false)}>
Abbrechen
</Button>
<Button type="submit" class="flex-1" loading={isCreating} disabled={!boardName.trim()}>
Erstellen
</Button>
</div>
</form>
</div>
</Modal>
<!-- Delete Confirmation Modal -->
<Modal open={showDeleteModal} onClose={() => (showDeleteModal = false)}>
<div class="p-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Board löschen?</h2>
<p class="mt-4 text-gray-600 dark:text-gray-400">
Möchtest du dieses Board wirklich löschen? Alle Bilder auf dem Board bleiben in deiner Galerie erhalten.
</p>
<div class="mt-6 flex gap-3">
<Button variant="outline" class="flex-1" onclick={() => (showDeleteModal = false)}>
Abbrechen
</Button>
<Button variant="danger" class="flex-1" onclick={handleDeleteBoard}>
Löschen
</Button>
</div>
</div>
</Modal>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { user } from '$lib/stores/auth';
import { currentBoard } from '$lib/stores/boards';
import { canvasItems, resetCanvasState, isLoadingCanvasItems, showPropertiesPanel, selectedItemIds, addCanvasItem } from '$lib/stores/canvas';
import { getBoardById } from '$lib/api/boards';
import { getBoardItems, addTextToBoard } from '$lib/api/boardItems';
import { showToast } from '$lib/stores/toast';
import BoardCanvas from '$lib/components/board/BoardCanvas.svelte';
import CanvasToolbar from '$lib/components/board/CanvasToolbar.svelte';
import ImagePickerModal from '$lib/components/board/ImagePickerModal.svelte';
import ImagePropertiesPanel from '$lib/components/board/ImagePropertiesPanel.svelte';
const boardId = $derived($page.params.id);
let showImagePicker = $state(false);
let isLoading = $state(true);
onMount(async () => {
if (!boardId) {
goto('/app/board');
return;
}
await loadBoard();
return () => {
resetCanvasState();
currentBoard.set(null);
};
});
async function loadBoard() {
if (!$user) return;
isLoading = true;
try {
// Load board metadata
const board = await getBoardById(boardId);
// Check if user has access
if (board.user_id !== $user.id && !board.is_public) {
showToast('Zugriff verweigert', 'error');
goto('/app/board');
return;
}
currentBoard.set(board);
// Load board items
isLoadingCanvasItems.set(true);
const items = await getBoardItems(boardId);
canvasItems.set(items);
} catch (error) {
console.error('Error loading board:', error);
showToast('Fehler beim Laden des Boards', 'error');
goto('/app/board');
} finally {
isLoading = false;
isLoadingCanvasItems.set(false);
}
}
function handleAddImages() {
showImagePicker = true;
}
async function handleAddText() {
if (!$user || !boardId) return;
try {
// Add text to center of visible area
const text = await addTextToBoard({
boardId,
content: 'Doppelklick zum Bearbeiten',
position: { x: 200, y: 200 }
});
// Add to canvas
addCanvasItem(text);
showToast('Text hinzugefügt', 'success');
} catch (error) {
console.error('Error adding text:', error);
showToast('Fehler beim Hinzufügen des Textes', 'error');
}
}
function handleBackToList() {
goto('/app/board');
}
// Auto-open panel when an item is selected
$effect(() => {
if ($selectedItemIds.length > 0) {
showPropertiesPanel.set(true);
}
});
</script>
<svelte:head>
<title>{$currentBoard?.name || 'Board'} - Picture</title>
</svelte:head>
{#if isLoading}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<p class="text-gray-600 dark:text-gray-400">Board wird geladen...</p>
</div>
</div>
{:else if $currentBoard}
<div class="relative flex h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-900">
<!-- Toolbar -->
<CanvasToolbar
boardName={$currentBoard.name}
onBack={handleBackToList}
onAddImages={handleAddImages}
onAddText={handleAddText}
/>
<!-- Canvas -->
<div class="flex-1 h-full pt-16">
<BoardCanvas />
</div>
<!-- Properties Panel (Right Side) with slide animation -->
<div
class="h-full pt-16 transition-all duration-300 ease-in-out {$showPropertiesPanel ? 'w-80' : 'w-0'}"
>
{#if $showPropertiesPanel}
<ImagePropertiesPanel />
{/if}
</div>
<!-- FAB: Properties Panel Toggle (Bottom Right) -->
<button
onclick={() => showPropertiesPanel.set(!$showPropertiesPanel)}
class="fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
title="{$showPropertiesPanel ? 'Eigenschaften ausblenden' : 'Eigenschaften anzeigen'}"
>
{#if $showPropertiesPanel}
<!-- Close Icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{:else}
<!-- Settings/Sliders Icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
{/if}
</button>
<!-- Image Picker Modal -->
<ImagePickerModal open={showImagePicker} onClose={() => (showImagePicker = false)} />
</div>
{/if}

View file

@ -0,0 +1,227 @@
<script lang="ts">
import {
exploreImages,
isLoadingExplore,
hasMoreExplore,
currentExplorePage,
exploreSortBy,
exploreSearchQuery,
showExploreFavoritesOnly
} from '$lib/stores/explore';
import { selectedImage } from '$lib/stores/images';
import { viewMode } from '$lib/stores/view';
import { getPublicImages, searchPublicImages } from '$lib/api/explore';
import ImageCard from '$lib/components/gallery/ImageCard.svelte';
import ImageDetailModal from '$lib/components/gallery/ImageDetailModal.svelte';
import ImageSkeleton from '$lib/components/ui/ImageSkeleton.svelte';
import ViewModeSwitcher from '$lib/components/ui/ViewModeSwitcher.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import { onMount } from 'svelte';
import type { Database } from '@picture/shared/types';
import type { ViewMode } from '$lib/stores/view';
type Image = Database['public']['Tables']['images']['Row'];
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
let searchInput = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// React to favorites filter changes
$effect(() => {
if ($showExploreFavoritesOnly !== undefined) {
loadInitialImages();
}
});
onMount(() => {
loadInitialImages();
// Setup Intersection Observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
if (
entries[0].isIntersecting &&
$hasMoreExplore &&
!$isLoadingExplore &&
!loadingMore
) {
loadMoreImages();
}
},
{
threshold: 0.1,
rootMargin: '100px' // Load before reaching the trigger
}
);
if (loadMoreTrigger) {
observer.observe(loadMoreTrigger);
}
return () => {
if (observer) observer.disconnect();
if (searchTimeout) clearTimeout(searchTimeout);
};
});
async function loadInitialImages() {
isLoadingExplore.set(true);
try {
const data = await getPublicImages({
page: 1,
sortBy: $exploreSortBy,
favoritesOnly: $showExploreFavoritesOnly
});
exploreImages.set(data);
currentExplorePage.set(1);
hasMoreExplore.set(data.length === 20);
} catch (error) {
console.error('Error loading explore images:', error);
} finally {
isLoadingExplore.set(false);
}
}
async function loadMoreImages() {
if (!$hasMoreExplore || $isLoadingExplore || loadingMore) return;
loadingMore = true;
const nextPage = $currentExplorePage + 1;
try {
const newImages = $exploreSearchQuery
? await searchPublicImages($exploreSearchQuery, nextPage, 20, $showExploreFavoritesOnly)
: await getPublicImages({
page: nextPage,
sortBy: $exploreSortBy,
favoritesOnly: $showExploreFavoritesOnly
});
if (newImages.length > 0) {
exploreImages.update((current) => [...current, ...newImages]);
currentExplorePage.set(nextPage);
hasMoreExplore.set(newImages.length === 20);
} else {
hasMoreExplore.set(false);
}
} catch (error) {
console.error('Error loading more explore images:', error);
} finally {
loadingMore = false;
}
}
function handleImageClick(image: Image) {
selectedImage.set(image);
}
function getGridClass(mode: ViewMode) {
switch (mode) {
case 'single':
return 'grid grid-cols-1 gap-4 max-w-2xl mx-auto';
case 'grid3':
return 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3';
case 'grid5':
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5';
}
}
function handleSortChange(e: Event) {
const target = e.target as HTMLSelectElement;
exploreSortBy.set(target.value as 'recent' | 'popular' | 'trending');
loadInitialImages();
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
searchInput = target.value;
// Debounce search
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
exploreSearchQuery.set(searchInput);
if (searchInput.trim()) {
isLoadingExplore.set(true);
try {
const results = await searchPublicImages(searchInput.trim(), 1, 20, $showExploreFavoritesOnly);
exploreImages.set(results);
currentExplorePage.set(1);
hasMoreExplore.set(results.length === 20);
} catch (error) {
console.error('Error searching:', error);
} finally {
isLoadingExplore.set(false);
}
} else {
loadInitialImages();
}
}, 500);
}
</script>
<svelte:head>
<title>Entdecken - Picture</title>
</svelte:head>
{#if $isLoadingExplore && $exploreImages.length === 0}
<div class="px-4 py-8">
<ImageSkeleton count={20} />
</div>
{:else if $exploreImages.length === 0}
<div class="flex min-h-[400px] items-center justify-center px-4">
<div class="text-center">
<svg
class="mx-auto h-16 w-16 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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"
/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">Keine Bilder gefunden</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">
{#if $exploreSearchQuery}
Keine Ergebnisse für "{$exploreSearchQuery}"
{:else}
Es sind noch keine öffentlichen Bilder vorhanden
{/if}
</p>
</div>
</div>
{:else}
<div class="px-4 py-8 pb-32">
<div class={getGridClass($viewMode)}>
{#each $exploreImages as image (image.id)}
<ImageCard {image} onclick={() => handleImageClick(image)} viewMode={$viewMode} />
{/each}
</div>
<!-- Infinite Scroll Trigger -->
{#if $hasMoreExplore}
<div bind:this={loadMoreTrigger} class="mt-8 flex justify-center">
{#if loadingMore}
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">Scrolle für mehr</p>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Image Detail Modal -->
<ImageDetailModal image={$selectedImage} onClose={() => selectedImage.set(null)} />
<!-- Context Menu -->
<ContextMenu />

View file

@ -0,0 +1,147 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import { images, isLoading, hasMore, currentPage, selectedImage, showFavoritesOnly } from '$lib/stores/images';
import { isUIVisible } from '$lib/stores/ui';
import { tags, selectedTags } from '$lib/stores/tags';
import { getImages } from '$lib/api/images';
import { getAllTags } from '$lib/api/tags';
import GalleryGrid from '$lib/components/gallery/GalleryGrid.svelte';
import ImageDetailModal from '$lib/components/gallery/ImageDetailModal.svelte';
import QuickGenerateBar from '$lib/components/gallery/QuickGenerateBar.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ImageSkeleton from '$lib/components/ui/ImageSkeleton.svelte';
import ViewModeSwitcher from '$lib/components/ui/ViewModeSwitcher.svelte';
import { onMount } from 'svelte';
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
onMount(async () => {
await loadTags();
loadInitialImages();
// Setup Intersection Observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && $hasMore && !$isLoading && !loadingMore) {
loadMoreImages();
}
},
{
threshold: 0.1,
rootMargin: '100px' // Load before reaching the trigger
}
);
if (loadMoreTrigger) {
observer.observe(loadMoreTrigger);
}
return () => {
if (observer) observer.disconnect();
};
});
async function loadTags() {
try {
const data = await getAllTags();
tags.set(data);
} catch (error) {
console.error('Error loading tags:', error);
}
}
// React to tag and favorites filter changes
$effect(() => {
if ($selectedTags || $showFavoritesOnly !== undefined) {
loadInitialImages();
}
});
async function loadInitialImages() {
if (!$user) return;
isLoading.set(true);
try {
const data = await getImages({
userId: $user.id,
page: 1,
tagIds: $selectedTags.length > 0 ? $selectedTags : undefined,
favoritesOnly: $showFavoritesOnly
});
images.set(data);
currentPage.set(1);
hasMore.set(data.length === 20);
} catch (error) {
console.error('Error loading images:', error);
} finally {
isLoading.set(false);
}
}
async function loadMoreImages() {
if (!$user || !$hasMore || $isLoading || loadingMore) return;
loadingMore = true;
const nextPage = $currentPage + 1;
try {
const newImages = await getImages({
userId: $user.id,
page: nextPage,
tagIds: $selectedTags.length > 0 ? $selectedTags : undefined,
favoritesOnly: $showFavoritesOnly
});
if (newImages.length > 0) {
images.update((current) => [...current, ...newImages]);
currentPage.set(nextPage);
hasMore.set(newImages.length === 20);
} else {
hasMore.set(false);
}
} catch (error) {
console.error('Error loading more images:', error);
} finally {
loadingMore = false;
}
}
</script>
<svelte:head>
<title>Gallery - Picture</title>
</svelte:head>
{#if $isLoading}
<div class="px-4 py-8">
<ImageSkeleton count={20} />
</div>
{:else}
<div class="px-4 py-8 pb-32">
<GalleryGrid images={$images} />
<!-- Infinite Scroll Trigger -->
{#if $hasMore}
<div bind:this={loadMoreTrigger} class="mt-8 flex justify-center">
{#if loadingMore}
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">Scroll to load more</p>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Image Detail Modal -->
<ImageDetailModal image={$selectedImage} onClose={() => selectedImage.set(null)} />
<!-- Context Menu -->
<ContextMenu />
<!-- Quick Generate Bar (conditionally visible) -->
{#if $isUIVisible}
<QuickGenerateBar onGenerated={loadInitialImages} />
{/if}

View file

@ -0,0 +1,102 @@
<script lang="ts">
import GenerateForm from '$lib/components/generate/GenerateForm.svelte';
</script>
<svelte:head>
<title>Generate - Picture</title>
</svelte:head>
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Generate Image</h1>
<p class="mt-2 text-gray-600">
Create stunning AI-generated images from your text descriptions
</p>
</div>
<div class="mx-auto max-w-3xl">
<GenerateForm />
</div>
<!-- Tips Section -->
<div class="mx-auto mt-8 max-w-3xl">
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
<h3 class="mb-3 text-lg font-semibold text-gray-900">Tips for better results:</h3>
<ul class="space-y-2 text-sm text-gray-700">
<li class="flex items-start">
<svg
class="mr-2 mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span><strong>Be specific:</strong> Include details about style, mood, colors, and composition</span
>
</li>
<li class="flex items-start">
<svg
class="mr-2 mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
><strong>Use descriptive words:</strong> "Vibrant sunset over mountains" is better than
"sunset"</span
>
</li>
<li class="flex items-start">
<svg
class="mr-2 mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
><strong>Negative prompts:</strong> Use to exclude unwanted elements (e.g., "blurry, distorted,
low quality")</span
>
</li>
<li class="flex items-start">
<svg
class="mr-2 mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
><strong>Try different models:</strong> Each model has unique strengths and artistic styles</span
>
</li>
</ul>
</div>
</div>
</div>

View file

@ -0,0 +1,177 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import { supabase } from '$lib/supabase';
import { goto } from '$app/navigation';
import Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ThemePicker from '$lib/components/settings/ThemePicker.svelte';
let isLoggingOut = $state(false);
async function handleLogout() {
isLoggingOut = true;
try {
const { error } = await supabase.auth.signOut();
if (error) throw error;
goto('/');
} catch (error) {
console.error('Error logging out:', error);
alert('Failed to log out');
} finally {
isLoggingOut = false;
}
}
function formatDate(dateString: string | undefined) {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric'
}).format(date);
}
</script>
<svelte:head>
<title>Profile - Picture</title>
</svelte:head>
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Profile</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your account settings</p>
</div>
<div class="mx-auto max-w-3xl space-y-6">
<!-- Account Information -->
<Card>
<div class="p-6">
<h2 class="mb-6 text-xl font-semibold text-gray-900 dark:text-gray-100">Account Information</h2>
<div class="space-y-4">
<!-- Email -->
<div class="flex items-center justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</h3>
<p class="mt-1 text-gray-900 dark:text-gray-100">{$user?.email || 'Not available'}</p>
</div>
{#if $user?.email_confirmed_at}
<span class="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800">
Verified
</span>
{:else}
<span class="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-800">
Not verified
</span>
{/if}
</div>
<!-- User ID -->
<div class="flex items-center justify-between border-b border-gray-200 pb-4">
<div>
<h3 class="text-sm font-medium text-gray-500">User ID</h3>
<p class="mt-1 font-mono text-sm text-gray-900">{$user?.id || 'Not available'}</p>
</div>
</div>
<!-- Created At -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-500">Member Since</h3>
<p class="mt-1 text-gray-900">{formatDate($user?.created_at)}</p>
</div>
</div>
</div>
</div>
</Card>
<!-- Theme Settings -->
<div>
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-gray-100">Appearance</h2>
<ThemePicker />
</div>
<!-- Settings -->
<Card>
<div class="p-6">
<h2 class="mb-6 text-xl font-semibold text-gray-900 dark:text-gray-100">Settings</h2>
<div class="space-y-4">
<!-- Language -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Language</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Select your preferred language
</p>
</div>
<select
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
</Card>
<!-- Statistics -->
<Card>
<div class="p-6">
<h2 class="mb-6 text-xl font-semibold text-gray-900">Statistics</h2>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="rounded-lg bg-blue-50 p-4">
<p class="text-sm font-medium text-blue-600">Total Images</p>
<p class="mt-2 text-2xl font-bold text-blue-900">-</p>
</div>
<div class="rounded-lg bg-green-50 p-4">
<p class="text-sm font-medium text-green-600">Generated</p>
<p class="mt-2 text-2xl font-bold text-green-900">-</p>
</div>
<div class="rounded-lg bg-purple-50 p-4">
<p class="text-sm font-medium text-purple-600">Archived</p>
<p class="mt-2 text-2xl font-bold text-purple-900">-</p>
</div>
</div>
<p class="mt-4 text-sm text-gray-500">Statistics coming soon...</p>
</div>
</Card>
<!-- Danger Zone -->
<Card>
<div class="p-6">
<h2 class="mb-6 text-xl font-semibold text-red-600">Danger Zone</h2>
<div class="space-y-4">
<div class="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 p-4">
<div>
<h3 class="font-medium text-red-900">Log Out</h3>
<p class="mt-1 text-sm text-red-700">Sign out of your account</p>
</div>
<Button variant="danger" onclick={handleLogout} loading={isLoggingOut}>
{isLoggingOut ? 'Logging out...' : 'Log Out'}
</Button>
</div>
<div class="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 p-4">
<div>
<h3 class="font-medium text-red-900">Delete Account</h3>
<p class="mt-1 text-sm text-red-700">
Permanently delete your account and all data
</p>
</div>
<Button
variant="danger"
onclick={() => alert('Account deletion is not yet implemented')}
>
Delete Account
</Button>
</div>
</div>
</div>
</Card>
</div>
</div>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
import { currentTheme } from '$lib/stores/theme';
import { showToast } from '$lib/stores/toast';
function handleSubscribe(planId: string) {
console.log('Subscribe to plan:', planId);
showToast(`Abo "${planId}" ausgewählt. Bezahlsystem wird noch integriert.`, 'info', 5000);
}
function handleBuyPackage(packageId: string) {
console.log('Buy package:', packageId);
showToast(`Paket "${packageId}" ausgewählt. Bezahlsystem wird noch integriert.`, 'info', 5000);
}
</script>
<svelte:head>
<title>Abonnement - Picture</title>
</svelte:head>
<div class="min-h-screen p-4 md:p-8" style="background-color: {$currentTheme.background};">
<div class="mx-auto max-w-6xl">
<SubscriptionPage
appName="Picture"
onSubscribe={handleSubscribe}
onBuyPackage={handleBuyPackage}
currentPlanId="free"
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
/>
</div>
</div>

View file

@ -0,0 +1,345 @@
<script lang="ts">
import { onMount } from 'svelte';
import { tags, isLoadingTags } from '$lib/stores/tags';
import { getAllTags, createTag, updateTag, deleteTag } from '$lib/api/tags';
import { showToast } from '$lib/stores/toast';
import type { Database } from '@picture/shared/types';
type Tag = Database['public']['Tables']['tags']['Row'];
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingTag = $state<Tag | null>(null);
let newTagName = $state('');
let newTagColor = $state('#3B82F6');
let editTagName = $state('');
let editTagColor = $state('');
const predefinedColors = [
'#EF4444', // red
'#F59E0B', // amber
'#10B981', // emerald
'#3B82F6', // blue
'#8B5CF6', // violet
'#EC4899', // pink
'#6366F1', // indigo
'#14B8A6' // teal
];
onMount(async () => {
await loadTags();
});
async function loadTags() {
isLoadingTags.set(true);
try {
const data = await getAllTags();
tags.set(data);
} catch (error) {
console.error('Error loading tags:', error);
showToast('Fehler beim Laden der Tags', 'error');
} finally {
isLoadingTags.set(false);
}
}
async function handleCreateTag() {
if (!newTagName.trim()) return;
try {
await createTag({
name: newTagName.trim(),
color: newTagColor
});
await loadTags();
showToast('Tag erfolgreich erstellt', 'success');
newTagName = '';
newTagColor = '#3B82F6';
showCreateModal = false;
} catch (error) {
console.error('Error creating tag:', error);
showToast('Fehler beim Erstellen des Tags', 'error');
}
}
function openEditModal(tag: Tag) {
editingTag = tag;
editTagName = tag.name;
editTagColor = tag.color || '#3B82F6';
showEditModal = true;
}
async function handleUpdateTag() {
if (!editingTag || !editTagName.trim()) return;
try {
await updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor
});
await loadTags();
showToast('Tag erfolgreich aktualisiert', 'success');
showEditModal = false;
editingTag = null;
} catch (error) {
console.error('Error updating tag:', error);
showToast('Fehler beim Aktualisieren des Tags', 'error');
}
}
async function handleDeleteTag(tagId: string) {
if (!confirm('Möchten Sie diesen Tag wirklich löschen?')) return;
try {
await deleteTag(tagId);
await loadTags();
showToast('Tag erfolgreich gelöscht', 'success');
} catch (error) {
console.error('Error deleting tag:', error);
showToast('Fehler beim Löschen des Tags', 'error');
}
}
</script>
<svelte:head>
<title>Tag-Verwaltung - Picture</title>
</svelte:head>
<div class="min-h-screen px-4 py-8">
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Tag-Verwaltung</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Verwalte deine Tags für eine bessere Organisation deiner Bilder
</p>
</div>
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 rounded-2xl bg-blue-600/90 px-6 py-3 text-sm font-medium text-white backdrop-blur-xl transition-all hover:bg-blue-700/90 dark:bg-blue-500/90 dark:hover:bg-blue-600/90"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neuer Tag
</button>
</div>
<!-- Tags Grid -->
{#if $isLoadingTags}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
</div>
{:else if $tags.length === 0}
<div class="rounded-3xl border border-gray-200/50 bg-white/80 p-12 text-center backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/80">
<svg
class="mx-auto h-16 w-16 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">Keine Tags vorhanden</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Erstelle deinen ersten Tag, um deine Bilder zu organisieren
</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each $tags as tag (tag.id)}
<div
class="group relative rounded-2xl border border-gray-200/50 bg-white/80 p-6 backdrop-blur-xl transition-all hover:shadow-lg dark:border-gray-700/50 dark:bg-gray-900/80"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
{#if tag.color}
<div
class="h-8 w-8 rounded-full"
style="background-color: {tag.color};"
></div>
{/if}
<div>
<h3 class="font-medium text-gray-900 dark:text-gray-100">{tag.name}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{tag.created_at ? new Date(tag.created_at).toLocaleDateString('de-DE') : ''}
</p>
</div>
</div>
<div class="flex gap-2">
<button
onclick={() => openEditModal(tag)}
class="rounded-lg bg-gray-100/80 p-2 text-gray-600 backdrop-blur-xl transition-all hover:bg-gray-200/80 dark:bg-gray-800/80 dark:text-gray-400 dark:hover:bg-gray-700/80"
aria-label="Bearbeiten"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onclick={() => handleDeleteTag(tag.id)}
class="rounded-lg bg-red-100/80 p-2 text-red-600 backdrop-blur-xl transition-all hover:bg-red-200/80 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/30"
aria-label="Löschen"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Create Tag Modal -->
{#if showCreateModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onclick={() => (showCreateModal = false)}
role="presentation"
>
<div
class="w-full max-w-md rounded-3xl border border-gray-200/50 bg-white/95 p-6 shadow-2xl backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/95"
onclick={(e) => e.stopPropagation()}
role="dialog"
>
<h2 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">Neuer Tag</h2>
<div class="space-y-4">
<div>
<label for="tag-name" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</label>
<input
id="tag-name"
type="text"
bind:value={newTagName}
placeholder="z.B. Landschaft, Portrait, etc."
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Farbe
</label>
<div class="flex flex-wrap gap-3">
{#each predefinedColors as color}
<button
onclick={() => (newTagColor = color)}
class="h-10 w-10 rounded-full transition-all {newTagColor === color
? 'ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-900'
: ''}"
style="background-color: {color}; {newTagColor === color ? `--tw-ring-color: ${color}` : ''}"
aria-label="Farbe auswählen"
></button>
{/each}
</div>
</div>
</div>
<div class="mt-6 flex gap-3">
<button
onclick={() => (showCreateModal = false)}
class="flex-1 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleCreateTag}
disabled={!newTagName.trim()}
class="flex-1 rounded-xl bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
Erstellen
</button>
</div>
</div>
</div>
{/if}
<!-- Edit Tag Modal -->
{#if showEditModal && editingTag}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onclick={() => (showEditModal = false)}
role="presentation"
>
<div
class="w-full max-w-md rounded-3xl border border-gray-200/50 bg-white/95 p-6 shadow-2xl backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/95"
onclick={(e) => e.stopPropagation()}
role="dialog"
>
<h2 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">Tag bearbeiten</h2>
<div class="space-y-4">
<div>
<label for="edit-tag-name" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</label>
<input
id="edit-tag-name"
type="text"
bind:value={editTagName}
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Farbe
</label>
<div class="flex flex-wrap gap-3">
{#each predefinedColors as color}
<button
onclick={() => (editTagColor = color)}
class="h-10 w-10 rounded-full transition-all {editTagColor === color
? 'ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-900'
: ''}"
style="background-color: {color}; {editTagColor === color ? `--tw-ring-color: ${color}` : ''}"
aria-label="Farbe auswählen"
></button>
{/each}
</div>
</div>
</div>
<div class="mt-6 flex gap-3">
<button
onclick={() => (showEditModal = false)}
class="flex-1 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleUpdateTag}
disabled={!editTagName.trim()}
class="flex-1 rounded-xl bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
Speichern
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { uploadMultipleImages, type UploadProgress } from '$lib/api/upload';
import { showToast } from '$lib/stores/toast';
import DropZone from '$lib/components/upload/DropZone.svelte';
import { images } from '$lib/stores/images';
let uploading = $state(false);
let uploadProgress = $state<UploadProgress[]>([]);
let successCount = $state(0);
async function handleFilesSelected(files: File[]) {
if (!$user) {
showToast('Bitte melde dich an', 'error');
return;
}
uploading = true;
successCount = 0;
try {
const uploadedImages = await uploadMultipleImages(files, $user.id, (progress) => {
uploadProgress = progress;
});
successCount = uploadedImages.length;
// Add uploaded images to store
images.update((current) => [...uploadedImages, ...current]);
if (successCount === files.length) {
showToast(
`${successCount} ${successCount === 1 ? 'Bild' : 'Bilder'} erfolgreich hochgeladen`,
'success'
);
} else {
showToast(
`${successCount} von ${files.length} Bildern erfolgreich hochgeladen`,
'warning'
);
}
// Redirect to gallery after successful upload
setTimeout(() => {
goto('/app/gallery');
}, 2000);
} catch (error) {
console.error('Upload error:', error);
showToast('Fehler beim Hochladen der Bilder', 'error');
} finally {
uploading = false;
}
}
</script>
<svelte:head>
<title>Upload - Picture</title>
</svelte:head>
<div class="min-h-screen px-4 py-8">
<div class="mx-auto max-w-5xl">
<!-- Header -->
<div class="mb-8">
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Bilder hochladen</h1>
<p class="text-gray-600 dark:text-gray-400">
Lade deine eigenen Bilder hoch und verwalte sie in deiner Galerie
</p>
</div>
<!-- Upload Success Banner -->
{#if successCount > 0 && !uploading}
<div
class="mb-6 flex items-center gap-3 rounded-2xl border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-green-950/20"
>
<svg class="h-6 w-6 flex-shrink-0 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<div>
<p class="font-medium text-green-900 dark:text-green-100">
Upload erfolgreich!
</p>
<p class="text-sm text-green-700 dark:text-green-300">
{successCount} {successCount === 1 ? 'Bild wurde' : 'Bilder wurden'} hochgeladen.
Du wirst zur Galerie weitergeleitet...
</p>
</div>
</div>
{/if}
<!-- Drop Zone -->
<DropZone onFilesSelected={handleFilesSelected} {uploading} {uploadProgress} />
<!-- Tips -->
{#if !uploading && uploadProgress.length === 0}
<div class="mt-8 grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">Unterstützte Formate</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
JPG, PNG und WebP Bilder werden unterstützt
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-950">
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">Maximale Größe</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Bis zu 10MB pro Bild
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-950">
<svg class="h-5 w-5 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">Batch Upload</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Lade mehrere Bilder gleichzeitig hoch
</p>
</div>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import PictureLogo from '$lib/components/branding/PictureLogo.svelte';
import { authService } from '$lib/services/authService';
// Default to German
const translations = getForgotPasswordTranslations('de');
async function handleForgotPassword(email: string) {
return authService.forgotPassword(email);
}
</script>
<svelte:head>
<title>Passwort vergessen - Picture</title>
</svelte:head>
<ForgotPasswordPage
appName="Picture"
logo={PictureLogo}
primaryColor="#3b82f6"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/auth/login"
lightBackground="#f0f9ff"
darkBackground="#0c1929"
{translations}
/>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { LoginPage, setGoogleClientId } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import PictureLogo from '$lib/components/branding/PictureLogo.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authService } from '$lib/services/authService';
import { onMount } from 'svelte';
import {
PUBLIC_GOOGLE_CLIENT_ID,
PUBLIC_APPLE_CLIENT_ID
} from '$env/static/public';
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));
onMount(() => {
if (PUBLIC_GOOGLE_CLIENT_ID) {
setGoogleClientId(PUBLIC_GOOGLE_CLIENT_ID);
}
});
async function handleSignIn(email: string, password: string) {
return authService.signIn(email, password);
}
async function handleSignInWithGoogle() {
return authService.signInWithGoogle();
}
async function handleSignInWithApple() {
return authService.signInWithApple();
}
</script>
<svelte:head>
<title>Anmelden - Picture</title>
</svelte:head>
<LoginPage
appName="Picture"
logo={PictureLogo}
primaryColor="#3b82f6"
onSignIn={handleSignIn}
onSignInWithGoogle={PUBLIC_GOOGLE_CLIENT_ID ? handleSignInWithGoogle : undefined}
onSignInWithApple={PUBLIC_APPLE_CLIENT_ID ? handleSignInWithApple : undefined}
{goto}
enableGoogle={!!PUBLIC_GOOGLE_CLIENT_ID}
enableApple={!!PUBLIC_APPLE_CLIENT_ID}
successRedirect="/app/gallery"
registerPath="/auth/signup"
forgotPasswordPath="/auth/forgot-password"
lightBackground="#f0f9ff"
darkBackground="#0c1929"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage, setGoogleClientId } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import PictureLogo from '$lib/components/branding/PictureLogo.svelte';
import { authService } from '$lib/services/authService';
import { onMount } from 'svelte';
import {
PUBLIC_GOOGLE_CLIENT_ID,
PUBLIC_APPLE_CLIENT_ID
} from '$env/static/public';
// Default to German
const translations = getRegisterTranslations('de');
onMount(() => {
if (PUBLIC_GOOGLE_CLIENT_ID) {
setGoogleClientId(PUBLIC_GOOGLE_CLIENT_ID);
}
});
async function handleSignUp(email: string, password: string) {
return authService.signUp(email, password);
}
async function handleSignUpWithGoogle() {
return authService.signInWithGoogle();
}
async function handleSignUpWithApple() {
return authService.signInWithApple();
}
</script>
<svelte:head>
<title>Registrieren - Picture</title>
</svelte:head>
<RegisterPage
appName="Picture"
logo={PictureLogo}
primaryColor="#3b82f6"
onSignUp={handleSignUp}
onSignUpWithGoogle={PUBLIC_GOOGLE_CLIENT_ID ? handleSignUpWithGoogle : undefined}
onSignUpWithApple={PUBLIC_APPLE_CLIENT_ID ? handleSignUpWithApple : undefined}
{goto}
enableGoogle={!!PUBLIC_GOOGLE_CLIENT_ID}
enableApple={!!PUBLIC_APPLE_CLIENT_ID}
successRedirect="/app/gallery"
loginPath="/auth/login"
lightBackground="#f0f9ff"
darkBackground="#0c1929"
{translations}
/>