mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 06:26:41 +02:00
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:
parent
c6c4c5a552
commit
c712a2504a
1031 changed files with 189301 additions and 290 deletions
12
picture/apps/web/src/app.css
Normal file
12
picture/apps/web/src/app.css
Normal 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
13
picture/apps/web/src/app.d.ts
vendored
Normal 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 {};
|
||||
11
picture/apps/web/src/app.html
Normal file
11
picture/apps/web/src/app.html
Normal 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>
|
||||
85
picture/apps/web/src/lib/analytics/posthog.ts
Normal file
85
picture/apps/web/src/lib/analytics/posthog.ts
Normal 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;
|
||||
411
picture/apps/web/src/lib/api/boardItems.ts
Normal file
411
picture/apps/web/src/lib/api/boardItems.ts
Normal 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;
|
||||
}
|
||||
226
picture/apps/web/src/lib/api/boards.ts
Normal file
226
picture/apps/web/src/lib/api/boards.ts
Normal 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 });
|
||||
}
|
||||
73
picture/apps/web/src/lib/api/explore.ts
Normal file
73
picture/apps/web/src/lib/api/explore.ts
Normal 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[];
|
||||
}
|
||||
250
picture/apps/web/src/lib/api/generate-async.ts
Normal file
250
picture/apps/web/src/lib/api/generate-async.ts
Normal 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
|
||||
}
|
||||
113
picture/apps/web/src/lib/api/generate.ts
Normal file
113
picture/apps/web/src/lib/api/generate.ts
Normal 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;
|
||||
}
|
||||
177
picture/apps/web/src/lib/api/images.ts
Normal file
177
picture/apps/web/src/lib/api/images.ts
Normal 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;
|
||||
}
|
||||
22
picture/apps/web/src/lib/api/models.ts
Normal file
22
picture/apps/web/src/lib/api/models.ts
Normal 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;
|
||||
}
|
||||
85
picture/apps/web/src/lib/api/tags.ts
Normal file
85
picture/apps/web/src/lib/api/tags.ts
Normal 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) || [];
|
||||
}
|
||||
160
picture/apps/web/src/lib/api/upload.ts
Normal file
160
picture/apps/web/src/lib/api/upload.ts
Normal 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
|
||||
}
|
||||
}
|
||||
1
picture/apps/web/src/lib/assets/favicon.svg
Normal file
1
picture/apps/web/src/lib/assets/favicon.svg
Normal 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 |
85
picture/apps/web/src/lib/components/AppSlider.svelte
Normal file
85
picture/apps/web/src/lib/components/AppSlider.svelte
Normal 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}
|
||||
/>
|
||||
21
picture/apps/web/src/lib/components/LanguageSelector.svelte
Normal file
21
picture/apps/web/src/lib/components/LanguageSelector.svelte
Normal 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"
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
136
picture/apps/web/src/lib/components/auth/LoginForm.svelte
Normal file
136
picture/apps/web/src/lib/components/auth/LoginForm.svelte
Normal 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>
|
||||
122
picture/apps/web/src/lib/components/auth/SignupForm.svelte
Normal file
122
picture/apps/web/src/lib/components/auth/SignupForm.svelte
Normal 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>
|
||||
644
picture/apps/web/src/lib/components/board/BoardCanvas.svelte
Normal file
644
picture/apps/web/src/lib/components/board/BoardCanvas.svelte
Normal 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>
|
||||
282
picture/apps/web/src/lib/components/board/CanvasToolbar.svelte
Normal file
282
picture/apps/web/src/lib/components/board/CanvasToolbar.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
>
|
||||
0°
|
||||
</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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
82
picture/apps/web/src/lib/components/gallery/ImageCard.svelte
Normal file
82
picture/apps/web/src/lib/components/gallery/ImageCard.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
242
picture/apps/web/src/lib/components/generate/GenerateForm.svelte
Normal file
242
picture/apps/web/src/lib/components/generate/GenerateForm.svelte
Normal 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>
|
||||
189
picture/apps/web/src/lib/components/layout/Header.svelte
Normal file
189
picture/apps/web/src/lib/components/layout/Header.svelte
Normal 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>
|
||||
594
picture/apps/web/src/lib/components/layout/Sidebar.svelte
Normal file
594
picture/apps/web/src/lib/components/layout/Sidebar.svelte
Normal 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>
|
||||
132
picture/apps/web/src/lib/components/settings/ThemePicker.svelte
Normal file
132
picture/apps/web/src/lib/components/settings/ThemePicker.svelte
Normal 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>
|
||||
56
picture/apps/web/src/lib/components/tags/TagPills.svelte
Normal file
56
picture/apps/web/src/lib/components/tags/TagPills.svelte
Normal 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>
|
||||
65
picture/apps/web/src/lib/components/ui/Button.svelte
Normal file
65
picture/apps/web/src/lib/components/ui/Button.svelte
Normal 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>
|
||||
19
picture/apps/web/src/lib/components/ui/Card.svelte
Normal file
19
picture/apps/web/src/lib/components/ui/Card.svelte
Normal 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>
|
||||
323
picture/apps/web/src/lib/components/ui/ContextMenu.svelte
Normal file
323
picture/apps/web/src/lib/components/ui/ContextMenu.svelte
Normal 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}
|
||||
32
picture/apps/web/src/lib/components/ui/ImageSkeleton.svelte
Normal file
32
picture/apps/web/src/lib/components/ui/ImageSkeleton.svelte
Normal 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>
|
||||
69
picture/apps/web/src/lib/components/ui/Input.svelte
Normal file
69
picture/apps/web/src/lib/components/ui/Input.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
80
picture/apps/web/src/lib/components/ui/Modal.svelte
Normal file
80
picture/apps/web/src/lib/components/ui/Modal.svelte
Normal 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}
|
||||
72
picture/apps/web/src/lib/components/ui/Toast.svelte
Normal file
72
picture/apps/web/src/lib/components/ui/Toast.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
272
picture/apps/web/src/lib/components/upload/DropZone.svelte
Normal file
272
picture/apps/web/src/lib/components/upload/DropZone.svelte
Normal 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>
|
||||
49
picture/apps/web/src/lib/i18n/index.ts
Normal file
49
picture/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
23
picture/apps/web/src/lib/i18n/locales/de.json
Normal file
23
picture/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
picture/apps/web/src/lib/i18n/locales/en.json
Normal file
23
picture/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
picture/apps/web/src/lib/index.ts
Normal file
1
picture/apps/web/src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
220
picture/apps/web/src/lib/services/authService.ts
Normal file
220
picture/apps/web/src/lib/services/authService.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
9
picture/apps/web/src/lib/stores/archive.ts
Normal file
9
picture/apps/web/src/lib/stores/archive.ts
Normal 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);
|
||||
6
picture/apps/web/src/lib/stores/auth.ts
Normal file
6
picture/apps/web/src/lib/stores/auth.ts
Normal 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);
|
||||
79
picture/apps/web/src/lib/stores/boards.ts
Normal file
79
picture/apps/web/src/lib/stores/boards.ts
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
274
picture/apps/web/src/lib/stores/canvas.ts
Normal file
274
picture/apps/web/src/lib/stores/canvas.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
58
picture/apps/web/src/lib/stores/contextMenu.ts
Normal file
58
picture/apps/web/src/lib/stores/contextMenu.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
12
picture/apps/web/src/lib/stores/explore.ts
Normal file
12
picture/apps/web/src/lib/stores/explore.ts
Normal 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);
|
||||
5
picture/apps/web/src/lib/stores/generate.ts
Normal file
5
picture/apps/web/src/lib/stores/generate.ts
Normal 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>('');
|
||||
11
picture/apps/web/src/lib/stores/images.ts
Normal file
11
picture/apps/web/src/lib/stores/images.ts
Normal 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);
|
||||
8
picture/apps/web/src/lib/stores/models.ts
Normal file
8
picture/apps/web/src/lib/stores/models.ts
Normal 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);
|
||||
29
picture/apps/web/src/lib/stores/sidebar.ts
Normal file
29
picture/apps/web/src/lib/stores/sidebar.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
8
picture/apps/web/src/lib/stores/tags.ts
Normal file
8
picture/apps/web/src/lib/stores/tags.ts
Normal 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);
|
||||
125
picture/apps/web/src/lib/stores/theme.ts
Normal file
125
picture/apps/web/src/lib/stores/theme.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
37
picture/apps/web/src/lib/stores/toast.ts
Normal file
37
picture/apps/web/src/lib/stores/toast.ts
Normal 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([]);
|
||||
}
|
||||
24
picture/apps/web/src/lib/stores/ui.ts
Normal file
24
picture/apps/web/src/lib/stores/ui.ts
Normal 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);
|
||||
35
picture/apps/web/src/lib/stores/view.ts
Normal file
35
picture/apps/web/src/lib/stores/view.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
15
picture/apps/web/src/lib/supabase.ts
Normal file
15
picture/apps/web/src/lib/supabase.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
79
picture/apps/web/src/routes/+layout.svelte
Normal file
79
picture/apps/web/src/routes/+layout.svelte
Normal 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 />
|
||||
23
picture/apps/web/src/routes/+page.svelte
Normal file
23
picture/apps/web/src/routes/+page.svelte
Normal 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 -->
|
||||
10
picture/apps/web/src/routes/app/+layout.server.ts
Normal file
10
picture/apps/web/src/routes/app/+layout.server.ts
Normal 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 {};
|
||||
};
|
||||
124
picture/apps/web/src/routes/app/+layout.svelte
Normal file
124
picture/apps/web/src/routes/app/+layout.svelte
Normal 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}
|
||||
154
picture/apps/web/src/routes/app/archive/+page.svelte
Normal file
154
picture/apps/web/src/routes/app/archive/+page.svelte
Normal 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 />
|
||||
388
picture/apps/web/src/routes/app/board/+page.svelte
Normal file
388
picture/apps/web/src/routes/app/board/+page.svelte
Normal 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>
|
||||
166
picture/apps/web/src/routes/app/board/[id]/+page.svelte
Normal file
166
picture/apps/web/src/routes/app/board/[id]/+page.svelte
Normal 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}
|
||||
227
picture/apps/web/src/routes/app/explore/+page.svelte
Normal file
227
picture/apps/web/src/routes/app/explore/+page.svelte
Normal 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 />
|
||||
147
picture/apps/web/src/routes/app/gallery/+page.svelte
Normal file
147
picture/apps/web/src/routes/app/gallery/+page.svelte
Normal 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}
|
||||
102
picture/apps/web/src/routes/app/generate/+page.svelte
Normal file
102
picture/apps/web/src/routes/app/generate/+page.svelte
Normal 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>
|
||||
177
picture/apps/web/src/routes/app/profile/+page.svelte
Normal file
177
picture/apps/web/src/routes/app/profile/+page.svelte
Normal 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>
|
||||
34
picture/apps/web/src/routes/app/subscription/+page.svelte
Normal file
34
picture/apps/web/src/routes/app/subscription/+page.svelte
Normal 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>
|
||||
345
picture/apps/web/src/routes/app/tags/+page.svelte
Normal file
345
picture/apps/web/src/routes/app/tags/+page.svelte
Normal 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}
|
||||
150
picture/apps/web/src/routes/app/upload/+page.svelte
Normal file
150
picture/apps/web/src/routes/app/upload/+page.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
65
picture/apps/web/src/routes/auth/login/+page.svelte
Normal file
65
picture/apps/web/src/routes/auth/login/+page.svelte
Normal 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>
|
||||
54
picture/apps/web/src/routes/auth/signup/+page.svelte
Normal file
54
picture/apps/web/src/routes/auth/signup/+page.svelte
Normal 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}
|
||||
/>
|
||||
Loading…
Add table
Add a link
Reference in a new issue