🎮 feat(games): add figgos game to monorepo

- Rename barbiebox to figgos and integrate into monorepo
- Remove separate git repository
- Update package name to @figgos/game
- Add dev scripts (figgos:dev, dev:figgos:ios, dev:figgos:android)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-04 16:26:25 +01:00
parent 03b77eec46
commit 949b9c85bc
70 changed files with 7769 additions and 0 deletions

25
games/figgos/.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# expo router
expo-env.d.ts
# firebase/supabase/vexo
.env
ios
android
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

View file

@ -0,0 +1,98 @@
# WTFigure Backend Architecture
This document provides an overview of the Supabase backend architecture for the WTFigure application.
## Database Structure
The WTFigure database consists of the following tables in the public schema:
### 1. Prompts Table
Stores templates for generating action figures.
| Column Name | Data Type | Nullable | Description |
|-------------|-----------|----------|-------------|
| id | bigint | NO | Primary key, auto-incrementing |
| name | character varying | YES | Name of the prompt template |
| template | text | YES | The actual prompt template content |
| created_at | timestamp with time zone | NO | Creation timestamp |
| updated_at | timestamp with time zone | YES | Last update timestamp |
### 2. Rarity Table
Defines rarity levels for action figures.
| Column Name | Data Type | Nullable | Description |
|-------------|-----------|----------|-------------|
| id | bigint | NO | Primary key, auto-incrementing |
| name | character varying | YES | Name of the rarity level (e.g., Common, Rare, Epic) |
| details | jsonb | YES | Additional details about the rarity level |
| created_at | timestamp with time zone | NO | Creation timestamp |
| updated_at | timestamp with time zone | YES | Last update timestamp |
### 3. Themes Table
Defines themes for action figures.
| Column Name | Data Type | Nullable | Description |
|-------------|-----------|----------|-------------|
| id | bigint | NO | Primary key, auto-incrementing |
| name | character varying | YES | Name of the theme |
| details | jsonb | YES | Additional details about the theme |
| created_at | timestamp with time zone | NO | Creation timestamp |
| updated_at | timestamp with time zone | YES | Last update timestamp |
## Edge Functions
### Static Image Generator
**Endpoint**: `/static-image-generator`
This edge function generates AI-created action figure images using OpenAI's image generation API.
**Functionality**:
- Accepts parameters for customizing the action figure (subject, theme, rarity)
- Generates a product-shot style image of an action figure in packaging
- Uploads the generated image to Supabase Storage
- Returns the public URL and metadata for the generated image
**Key Features**:
- Integrates with OpenAI for image generation
- Supports custom prompts based on themes and rarities
- Stores generated images in Supabase Storage
- Handles error cases gracefully
**Implementation Details**:
- Uses OpenAI's image generation and editing capabilities
- Dynamically constructs prompts based on the subject, theme, and rarity
- Generates unique filenames for each image
- Uploads images to the 'figures' storage bucket
- Returns structured metadata along with the image URL
## Authentication
The WTFigure app uses Supabase Authentication with the following features:
- Email/password authentication
- Session persistence using AsyncStorage
- Protected routes requiring authentication
## Storage
The application uses Supabase Storage with the following buckets:
- `figures`: Stores AI-generated action figure images
## Environment Configuration
The application connects to Supabase using the following environment variables:
- `EXPO_PUBLIC_SUPABASE_URL`: The URL of your Supabase project
- `EXPO_PUBLIC_SUPABASE_ANON_KEY`: The anonymous API key for client-side access
## Integration with Frontend
The WTFigure frontend is a React Native Expo application that:
- Displays action figures in horizontal and vertical layouts
- Supports light/dark theme switching
- Provides user authentication flows
- Allows creation of custom action figures
The frontend connects to this Supabase backend using the Supabase JavaScript client.

View file

@ -0,0 +1,467 @@
# Figgos Create Page Integration
This document outlines the integration between the Create page and the Supabase figgos-generator edge function.
## Database Structure
We've created a new `figures` table with the following structure:
```sql
CREATE TABLE public.figures (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR NOT NULL,
subject VARCHAR NOT NULL,
image_url VARCHAR NOT NULL,
theme VARCHAR NOT NULL,
rarity VARCHAR NOT NULL,
character_description TEXT,
style_description TEXT,
accessory1_description TEXT NOT NULL,
accessory2_description TEXT NOT NULL,
accessory3_description TEXT NOT NULL,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
likes INTEGER DEFAULT 0,
is_public BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
```
This table includes:
- Basic figure information (name, image URL)
- Direct storage of theme and rarity values as strings
- Character and accessory descriptions
- User ownership, likes, and public/private setting
- Timestamps for creation and updates
## Integration Steps
### 1. Create a Supabase Client Utility
First, we need to enhance our existing Supabase client to handle the figure generation:
```typescript
// utils/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
import { ExtendedFigureData } from '../components/SidebarCreateFigureForm';
import * as FileSystem from 'expo-file-system';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Function to fetch themes from the database
export async function fetchThemes() {
const { data, error } = await supabase.from('themes').select('name, details');
if (error) {
console.error('Error fetching themes:', error);
return [];
}
return data;
}
// Function to fetch rarities from the database
export async function fetchRarities() {
const { data, error } = await supabase.from('rarity').select('name, details');
if (error) {
console.error('Error fetching rarities:', error);
return [];
}
return data;
}
// Function to generate a figure using the edge function
export async function generateFigure(
formData: ExtendedFigureData,
themeName: string,
rarityName: string
) {
try {
// Convert image to base64 if it exists
let faceImageBase64 = null;
if (formData.characterImage) {
// For web
if (formData.characterImage.startsWith('data:')) {
faceImageBase64 = formData.characterImage.split(',')[1];
}
// For native
else {
const base64 = await FileSystem.readAsStringAsync(formData.characterImage, {
encoding: FileSystem.EncodingType.Base64,
});
faceImageBase64 = base64;
}
}
// Prepare the request payload
const payload = {
subject: formData.name,
theme: formData.style?.description || 'Dark Professional',
rarity: 'common', // Default rarity, can be customized
clothing_description: formData.characterDescription,
accessory1_description: formData.artifacts[0].description || 'Standard accessory',
accessory2_description: formData.artifacts[1].description || 'Standard accessory',
accessory3_description: formData.artifacts[2].description || 'Standard accessory',
face_image: faceImageBase64,
};
// Call the edge function
const { data, error } = await supabase.functions.invoke('figgos-generator', {
body: JSON.stringify(payload),
});
if (error) {
console.error('Error generating figure:', error);
throw new Error(error.message);
}
// Save the figure to the database
const { data: figureData, error: figureError } = await supabase
.from('figures')
.insert({
name: formData.name,
subject: formData.name,
image_url: data.image_url,
theme: themeName,
rarity: rarityName,
character_description: formData.characterDescription,
style_description: formData.style?.description,
accessory1_description: formData.artifacts[0].description,
accessory2_description: formData.artifacts[1].description,
accessory3_description: formData.artifacts[2].description,
is_public: isPublic,
user_id: (await supabase.auth.getUser()).data.user?.id,
})
.select()
.single();
if (figureError) {
console.error('Error saving figure:', figureError);
throw new Error(figureError.message);
}
return figureData;
} catch (error) {
console.error('Error in generateFigure:', error);
throw error;
}
}
```
### 2. Update the SidebarCreateFigureForm Component
Modify the form component to use the new figure generation function:
```typescript
// components/SidebarCreateFigureForm.tsx
// Add imports
import { useState, useEffect } from 'react';
import { fetchThemes, fetchRarities, generateFigure } from '~/utils/supabaseClient';
import { useAuth } from '~/utils/AuthContext';
import { router } from 'expo-router';
// Add state for themes and rarities
const [themes, setThemes] = useState([]);
const [rarities, setRarities] = useState([]);
const [selectedTheme, setSelectedTheme] = useState('');
const [selectedRarity, setSelectedRarity] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const { user } = useAuth();
// Add useEffect to fetch themes and rarities
useEffect(() => {
async function loadData() {
const themesData = await fetchThemes();
const raritiesData = await fetchRarities();
setThemes(themesData);
setRarities(raritiesData);
// Set defaults
if (themesData.length > 0) setSelectedTheme(themesData[0].name);
if (raritiesData.length > 0) setSelectedRarity(raritiesData[0].name);
}
loadData();
}, []);
// Update the handleSubmit function
const handleSubmit = async () => {
// Validate form
if (!formData.name.trim()) {
Alert.alert('Error', 'Please enter a name.');
return;
}
if (!formData.characterDescription.trim()) {
Alert.alert('Error', 'Please describe your character.');
return;
}
// Check if all three artifacts have descriptions
const artifactDescriptions = formData.artifacts.map((a) => a.description.trim());
if (artifactDescriptions.some((desc) => desc === '')) {
Alert.alert('Error', 'Please describe all three accessories.');
return;
}
if (!formData.style?.description.trim()) {
Alert.alert('Error', 'Please describe the style.');
return;
}
if (!user) {
Alert.alert('Error', 'You must be logged in to create a figure.');
return;
}
try {
setIsGenerating(true);
// Generate the figure
const figure = await generateFigure(formData, selectedTheme, selectedRarity);
// Show success message
Alert.alert('Success!', 'Your action figure has been created!', [
{
text: 'View My Shelf',
onPress: () => router.push('/myshelf'),
},
{
text: 'OK',
style: 'cancel',
},
]);
// Reset form
setFormData({
name: '',
characterDescription: '',
characterImage: null,
artifacts: [
{ description: '', image: null },
{ description: '', image: null },
{ description: '', image: null },
],
style: {
description: '',
},
});
} catch (error) {
console.error('Error creating figure:', error);
Alert.alert('Error', 'There was a problem creating your figure. Please try again.');
} finally {
setIsGenerating(false);
}
};
```
### 3. Add Theme and Rarity Selection to the Form
Add dropdowns to the form to allow users to select themes and rarities:
```jsx
{
/* Theme Selection */
}
<View style={styles.sectionContainer}>
<View style={styles.sectionHeader}>
<View style={styles.iconContainer}>
<FontAwesome name="image" size={20} color={theme.colors.text} />
</View>
<Text style={[styles.sectionTitle, { color: theme.colors.text }]}>Theme</Text>
</View>
<View
style={[
styles.pickerContainer,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.input },
]}
>
<Picker
selectedValue={selectedTheme}
onValueChange={(itemValue) => setSelectedTheme(itemValue)}
style={{ color: theme.colors.text }}
>
{themes.map((themeItem) => (
<Picker.Item key={themeItem.name} label={themeItem.name} value={themeItem.name} />
))}
</Picker>
</View>
</View>;
{
/* Rarity Selection */
}
<View style={styles.sectionContainer}>
<View style={styles.sectionHeader}>
<View style={styles.iconContainer}>
<FontAwesome name="star" size={20} color={theme.colors.text} />
</View>
<Text style={[styles.sectionTitle, { color: theme.colors.text }]}>Rarity</Text>
</View>
<View
style={[
styles.pickerContainer,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.input },
]}
>
<Picker
selectedValue={selectedRarity}
onValueChange={(itemValue) => setSelectedRarity(itemValue)}
style={{ color: theme.colors.text }}
>
{rarities.map((rarityItem) => (
<Picker.Item key={rarityItem.name} label={rarityItem.name} value={rarityItem.name} />
))}
</Picker>
</View>
</View>;
{
/* Visibility Selection */
}
<View style={styles.sectionContainer}>
<View style={styles.sectionHeader}>
<View style={styles.iconContainer}>
<FontAwesome name="globe" size={20} color={theme.colors.text} />
</View>
<Text style={[styles.sectionTitle, { color: theme.colors.text }]}>Visibility</Text>
</View>
<View
style={[
styles.switchContainer,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.input },
]}
>
<Text style={{ color: theme.colors.text }}>Make this figure public</Text>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: theme.colors.border, true: theme.colors.primary }}
thumbColor={isPublic ? theme.colors.card : '#f4f3f4'}
/>
</View>
</View>;
```
### 4. Add Loading State to the Submit Button
Update the submit button to show a loading indicator:
```jsx
<TouchableOpacity
style={[
styles.submitButton,
{
backgroundColor: isGenerating ? theme.colors.border : theme.colors.primary,
opacity: isGenerating ? 0.7 : 1,
},
]}
onPress={handleSubmit}
disabled={isGenerating}
>
{isGenerating ? (
<ActivityIndicator color="#fff" size="small" style={{ marginRight: 10 }} />
) : (
<FontAwesome name="check" size={20} color="#fff" style={{ marginRight: 10 }} />
)}
<Text style={styles.submitButtonText}>{isGenerating ? 'Creating...' : 'Create Figure'}</Text>
</TouchableOpacity>
```
## 5. Update the MyShelf Page
Enhance the MyShelf page to display the user's created figures:
```typescript
// app/(tabs)/myshelf.tsx
import { useEffect, useState } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { supabase } from '~/utils/supabaseClient';
import { useAuth } from '~/utils/AuthContext';
import { HorizontalFigureCard } from '~/components/HorizontalFigureCard';
export default function MyShelf() {
const [figures, setFigures] = useState([]);
const [loading, setLoading] = useState(true);
const { user } = useAuth();
useEffect(() => {
async function loadFigures() {
if (!user) return;
try {
setLoading(true);
const { data, error } = await supabase
.from('figures')
.select(
`
id,
name,
subject,
image_url,
theme,
rarity,
likes,
is_public
`
)
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) {
console.error('Error loading figures:', error);
return;
}
setFigures(data || []);
} catch (error) {
console.error('Error in loadFigures:', error);
} finally {
setLoading(false);
}
}
loadFigures();
}, [user]);
// Rest of the component...
}
```
## Testing Plan
1. **Unit Testing**:
- Test the `generateFigure` function with various inputs
- Validate form submission with different combinations of data
2. **Integration Testing**:
- Test the end-to-end flow from form submission to figure creation
- Verify that the figure appears in the user's shelf after creation
3. **Error Handling**:
- Test with invalid inputs to ensure proper error messages
- Test with network failures to ensure graceful degradation
## Deployment Steps
1. Deploy the updated code to your Expo project
2. Verify that the edge function is accessible and working
3. Monitor the first few figure creations to ensure everything works as expected
## Future Enhancements
1. **Progress Tracking**: Add a progress bar during figure generation
2. **Image Preview**: Show a preview of the generated image before saving
3. **Social Sharing**: Add ability to share figures directly to social media
4. **Figure Collections**: Allow users to organize figures into collections
5. **Figure Editing**: Allow users to edit their existing figures

View file

@ -0,0 +1,238 @@
# OpenAI GPT Image v1 API Dokumentation
Diese Dokumentation konzentriert sich auf die neue OpenAI GPT Image v1 API (gpt-image-1), die transparente Hintergründe und andere fortschrittliche Funktionen unterstützt.
# OpenAI GPT Image v1 API Dokumentation
Diese Dokumentation konzentriert sich auf die neue OpenAI GPT Image v1 API (gpt-image-1), die transparente Hintergründe und andere fortschrittliche Funktionen unterstützt.
## Der Parameter `background`
Der Parameter `background` in der OpenAI Image v1 API (gpt-image-1) steuert den Hintergrundtyp des generierten Bildes. Mit diesem Parameter kannst du gezielt festlegen, ob das Bild einen transparenten, undurchsichtigen (opaken) oder automatisch gewählten Hintergrund haben soll.
### Mögliche Werte für background:
- **"transparent"**: Das Bild wird mit transparentem Hintergrund erzeugt. Dies funktioniert nur, wenn das Ausgabeformat (`output_format`) auf "png" oder "webp" gesetzt ist, da nur diese Formate Transparenz unterstützen.
- **"opaque"**: Das Bild erhält einen vollständig undurchsichtigen Hintergrund.
- **"auto"**: Die API entscheidet automatisch, welcher Hintergrundtyp verwendet wird (Standardwert).
### Wichtige Hinweise:
- Für Transparenz muss das Ausgabeformat png oder webp sein. JPEG unterstützt keine Transparenz.
- Transparenz funktioniert am besten mit mittlerer oder hoher Qualitätsstufe (quality: "medium" oder "high").
- Es reicht nicht, nur im Prompt nach „transparent background" zu fragen der Parameter `background` sollte explizit gesetzt werden.
### Beispiel für einen API-Request mit transparentem Hintergrund:
````python
result = client.images.generate(
model="gpt-image-1",
prompt="Vector art icon of a stylized rocket ship, transparent background",
size="1024x1024",
quality="high",
output_format="webp",
background="transparent",
n=1
)
Damit erhältst du ein Bild mit echtem transparentem Hintergrund, das sich z.B. für Icons oder Overlays eignet.
OpenAI GPT Image v1 API Übersicht
1. Bilder generieren
Endpunkt: POST /v1/images/generations
Wichtige Parameter:
model: "gpt-image-1"
prompt: Beschreibung des gewünschten Bildes (Pflichtfeld)
n: Anzahl der Bilder (optional, Standard: 1)
size: Bildgröße, z.B. "1024x1024" (optional)
quality: "standard" oder "high" (optional)
style: "vivid" oder "natural" (optional)
background: "transparent", "opaque", "auto" (optional)
output_format: "png", "webp", "jpeg" (optional, für Transparenz: png/webp)
Beispiel:
json
CopyInsert
{
"model": "gpt-image-1",
"prompt": "A vector icon of a rocket, transparent background",
"n": 1,
"size": "1024x1024",
"quality": "high",
"output_format": "webp",
"background": "transparent"
}
2. Bilder bearbeiten
Endpunkt: POST /v1/images/edits
Wichtige Parameter:
model: "gpt-image-1"
image: Hochzuladende Bilddatei (Pflichtfeld)
prompt: Beschreibung der Änderung (Pflichtfeld)
mask: Optionales Maskenbild (optional)
Weitere Parameter wie bei Bildgenerierung
3. Bildvariationen erstellen
Endpunkt: POST /v1/images/variations
Wichtige Parameter:
image: Hochzuladende Bilddatei (Pflichtfeld)
Weitere Parameter wie bei Bildgenerierung
Parameter background im Detail
| Wert | Bedeutung | Hinweis | |------|-----------|--------| | "transparent" | Bild mit transparentem Hintergrund (nur mit PNG/WebP) | Für Icons, Overlays etc. geeignet | | "opaque" | Bild mit undurchsichtigem Hintergrund | Standard für Fotos | | "auto" | Automatische Auswahl durch die API | Standardwert |
Achtung:
Für Transparenz muss output_format auf "png" oder "webp" stehen!
Transparenz funktioniert am besten mit quality: "high".
GPT Image v1 - Funktionen und Fähigkeiten
Überblick
Die GPT Image v1 API (gpt-image-1) ist ein nativ multimodales großes Sprachmodell. Es kann sowohl Text als auch Bilder verstehen und sein umfassendes Weltwissen nutzen, um Bilder mit besserer Befolgung von Anweisungen und kontextuellem Bewusstsein zu generieren.
Bildgenerierung mit Weltwissen
Als nativ multimodales Sprachmodell kann GPT Image sein visuelles Verständnis der Welt nutzen, um lebensechte Bilder einschließlich realer Details ohne Referenz zu generieren.
Beispiel: Wenn du GPT Image aufforderst, ein Bild einer Glasvitrine mit den beliebtesten Halbedelsteinen zu generieren, weiß das Modell genug, um Edelsteine wie Amethyst, Rosenquarz, Jade usw. auszuwählen und sie auf realistische Weise darzustellen.
Beispiele für die Verwendung der GPT Image v1 API
Bild mit transparentem Hintergrund generieren
bash
CopyInsert
curl https://api.openai.com/v1/images/generations \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
"model": "gpt-image-1",
"prompt": "Vector art icon of a stylized fox, transparent background",
"n": 1,
"size": "1024x1024",
"quality": "high",
"background": "transparent",
"output_format": "webp"
}'
Bild mit JavaScript generieren
javascript
CopyInsert
async function generateImage() {
const response = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENAI_API_KEY}`
},
body: JSON.stringify({
model: "gpt-image-1",
prompt: "Vector art icon of a stylized fox, transparent background",
n: 1,
size: "1024x1024",
quality: "high",
background: "transparent",
output_format: "webp"
})
});
const result = await response.json();
return result;
}
Authentifizierung
Immer den Header setzen: Authorization: Bearer <DEIN_API_KEY>
## Response-Format und Base64-Encoding
### Wichtige Hinweise zum Response-Format:
- Die GPT Image v1 API kann entweder eine URL oder einen Base64-String zurückgeben.
- Der Parameter `response_format` wird **nicht** unterstützt, im Gegensatz zu DALL-E 3.
- Die API kann zwei verschiedene Antwortformate zurückgeben:
1. **URL-Format**:
```json
{
"created": 1683245297,
"data": [
{
"url": "https://..."
}
]
}
````
2. **Base64-Format**:
```json
{
"created": 1683245297,
"data": [
{
"b64_json": "iVBORw0KGgoAAAANSUhEUgAA..."
}
]
}
```
- Deine Implementierung sollte beide Formate unterstützen, da die API je nach Anfrage und Konfiguration unterschiedliche Formate zurückgeben kann.
### Beispiel für die Verarbeitung beider Antwortformate:
```javascript
const response = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-image-1',
prompt: 'Vector art icon of a stylized fox, transparent background',
n: 1,
size: '1024x1024',
quality: 'high',
background: 'transparent',
output_format: 'png',
}),
});
const result = await response.json();
// Prüfe, ob die Antwort eine URL oder Base64-Daten enthält
const imageUrl = result.data?.[0]?.url;
const imageBase64 = result.data?.[0]?.b64_json;
let imageBlob;
if (imageUrl) {
// Fall 1: URL wurde zurückgegeben
const imageResponse = await fetch(imageUrl);
imageBlob = await imageResponse.blob();
} else if (imageBase64) {
// Fall 2: Base64-Daten wurden zurückgegeben
const binaryString = atob(imageBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
imageBlob = new Blob([bytes], { type: 'image/png' });
} else {
throw new Error('Keine Bilddaten in der Antwort gefunden');
}
// Jetzt kann das Bild verwendet werden
// z.B. zum Anzeigen in einem <img>-Element oder zum Speichern
```
## Zusammenfassung
Mit der OpenAI GPT Image v1 API kannst du:
- Bilder mit hoher Qualität generieren
- Mit dem Parameter background gezielt den Hintergrundtyp steuern
- Transparente Hintergründe für Icons und Grafiken erstellen
- Bilder über die zurückgegebene URL herunterladen und weiterverarbeiten
Tipp für transparente Icons, Logos oder Sticker: Verwende background: "transparent" und output_format: "png" oder "webp"

View file

@ -0,0 +1,261 @@
Model context protocol (MCP)
Connect your AI tools to Supabase using MCP
The Model Context Protocol (MCP) is a standard for connecting Large Language Models (LLMs) to platforms like Supabase. This guide covers how to connect Supabase to the following AI tools using MCP:
Cursor
Windsurf (Codium)
Visual Studio Code (Copilot)
Cline (VS Code extension)
Claude desktop
Claude code
Once connected, your AI assistants can interact with and query your Supabase projects on your behalf.
Step 1: Create a personal access token (PAT)#
First, go to your Supabase settings and create a personal access token. Give it a name that describes its purpose, like "Cursor MCP Server". This will be used to authenticate the MCP server with your Supabase account.
Step 2: Configure in your AI tool#
MCP compatible tools can connect to Supabase using the Supabase MCP server. Below are instructions for connecting to this server using popular AI tools:
Cursor#
Open Cursor and create a .cursor directory in your project root if it doesn't exist.
Create a .cursor/mcp.json file if it doesn't exist and open it.
Add the following configuration:
macOS
Windows
Windows (WSL)
Linux
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase@latest",
"--access-token",
"<personal-access-token>"
]
}
}
}
Replace <personal-access-token> with your personal access token.
Save the configuration file.
Open Cursor and navigate to Settings/MCP. You should see a green active status after the server is successfully connected.
Windsurf#
Open Windsurf and navigate to the Cascade assistant.
Tap on the hammer (MCP) icon, then Configure to open the configuration file.
Add the following configuration:
macOS
Windows
Windows (WSL)
Linux
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase@latest",
"--access-token",
"<personal-access-token>"
]
}
}
}
Replace <personal-access-token> with your personal access token.
Save the configuration file and reload by tapping Refresh in the Cascade assistant.
You should see a green active status after the server is successfully connected.
Visual Studio Code (Copilot)#
Open VS Code and create a .vscode directory in your project root if it doesn't exist.
Create a .vscode/mcp.json file if it doesn't exist and open it.
Add the following configuration:
macOS
Windows
Windows (WSL)
Linux
{
"inputs": [
{
"type": "promptString",
"id": "supabase-access-token",
"description": "Supabase personal access token",
"password": true
}
],
"servers": {
"supabase": {
"command": "npx",
"args": ["-y", "@supabase/mcp-server-supabase@latest"],
"env": {
"SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
}
}
}
}
Save the configuration file.
Open Copilot chat and switch to "Agent" mode. You should see a tool icon that you can tap to confirm the MCP tools are available. Once you begin using the server, you will be prompted to enter your personal access token. Enter the token that you created earlier.
For more info on using MCP in VS Code, see the Copilot documentation.
Cline#
Open the Cline extension in VS Code and tap the MCP Servers icon.
Tap Configure MCP Servers to open the configuration file.
Add the following configuration:
macOS
Windows
Windows (WSL)
Linux
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase@latest",
"--access-token",
"<personal-access-token>"
]
}
}
}
Replace <personal-access-token> with your personal access token.
Save the configuration file. Cline should automatically reload the configuration.
You should see a green active status after the server is successfully connected.
Claude desktop#
Open Claude desktop and navigate to Settings.
Under the Developer tab, tap Edit Config to open the configuration file.
Add the following configuration:
macOS
Windows
Windows (WSL)
Linux
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase@latest",
"--access-token",
"<personal-access-token>"
]
}
}
}
Replace <personal-access-token> with your personal access token.
Save the configuration file and restart Claude desktop.
From the new chat screen, you should see a hammer (MCP) icon appear with the new MCP server available.
Claude code#
Create a .mcp.json file in your project root if it doesn't exist.
Add the following configuration:
macOS
Windows
Windows (WSL)
Linux
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase@latest",
"--access-token",
"<personal-access-token>"
]
}
}
}
Replace <personal-access-token> with your personal access token.
Save the configuration file.
Restart Claude code to apply the new configuration.
Next steps#
Your AI tool is now connected to Supabase using MCP. Try asking your AI assistant to create a new project, create a table, or fetch project config.
For a full list of tools available, see the GitHub README. If you experience any issues, submit an bug report.
MCP for local Supabase instances#
The Supabase MCP server connects directly to the cloud platform to access your database. If you are running a local instance of Supabase, you can instead use the Postgres MCP server to connect to your local database. This MCP server runs all queries as read-only transactions.
Step 1: Find your database connection string#
To connect to your local Supabase instance, you need to get the connection string for your local database. You can find your connection string by running:
supabase status
or if you are using npx:
npx supabase status
This will output a list of details about your local Supabase instance. Copy the DB URL field in the output.
Step 2: Configure the MCP server#
Configure your client with the following:
macOS
Windows
Windows (WSL)
Linux
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "<connection-string>"]
}
}
}
Replace <connection-string> with your connection string.
Next steps#
Your AI tool is now connected to your local Supabase instance using MCP. Try asking the AI tool to query your database using natural language commands.

2
games/figgos/app-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
// @ts-ignore
/// <reference types="nativewind/types" />

58
games/figgos/app.json Normal file
View file

@ -0,0 +1,58 @@
{
"expo": {
"name": "Figgos",
"slug": "figgos",
"version": "1.0.0",
"scheme": "figgos",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-dev-launcher",
{
"launchMode": "most-recent"
}
]
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.memoro.figgos",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.memoro.figgos"
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "cd5ac6b1-3491-4205-b0d3-3f8c081bae06"
}
},
"owner": "memoro"
}
}

View file

@ -0,0 +1,21 @@
import { Stack } from 'expo-router';
import { useTheme } from '../../utils/ThemeContext';
export default function AuthLayout() {
const { theme } = useTheme();
return (
<Stack
screenOptions={{
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTintColor: theme.colors.text,
headerTitleStyle: {
fontWeight: 'bold',
},
headerShown: false,
}}
/>
);
}

View file

@ -0,0 +1,596 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Keyboard,
ImageBackground,
Platform,
Pressable,
Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useAuth } from '../../utils/AuthContext';
import { useTheme } from '../../utils/ThemeContext';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { BlurView } from 'expo-blur';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
// Validate email format
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Validate password strength
const validatePassword = (password: string): string | null => {
if (password.length < 6) return 'Das Passwort muss mindestens 6 Zeichen lang sein';
if (!/[A-Z]/.test(password)) return 'Das Passwort muss mindestens einen Großbuchstaben enthalten';
if (!/[a-z]/.test(password))
return 'Das Passwort muss mindestens einen Kleinbuchstaben enthalten';
if (!/[0-9]/.test(password)) return 'Das Passwort muss mindestens eine Zahl enthalten';
return null;
};
type AuthMode = 'initial' | 'login' | 'register';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [mode, setMode] = useState<AuthMode>('initial');
const [isResetPassword, setIsResetPassword] = useState(false);
const { signIn, signUp } = useAuth();
const router = useRouter();
const { theme, isDark } = useTheme();
// Clear error messages when inputs change
const handleEmailChange = (text: string) => {
setEmail(text);
setEmailError(null);
};
const handlePasswordChange = (text: string) => {
setPassword(text);
setPasswordError(null);
};
const handleConfirmPasswordChange = (text: string) => {
setConfirmPassword(text);
setPasswordError(null);
};
// Handle password reset
const handleResetPassword = async () => {
setEmailError(null);
if (!email) {
setEmailError('Bitte gib deine E-Mail-Adresse ein');
return;
}
if (!validateEmail(email)) {
setEmailError('Bitte gib eine gültige E-Mail-Adresse ein');
return;
}
setLoading(true);
try {
// This is a placeholder - Supabase doesn't have a direct method for this
// You would need to implement this in your backend or use Supabase's email templates
Alert.alert(
'Funktion nicht verfügbar',
'Die Passwort-Zurücksetzen-Funktion ist noch nicht implementiert.'
);
setMode('login');
setIsResetPassword(false);
} catch (error) {
console.error('Password reset error:', error);
Alert.alert('Fehler', 'Ein unbekannter Fehler ist aufgetreten');
} finally {
setLoading(false);
}
};
// Handle login
const handleLogin = async () => {
// Dismiss keyboard
Keyboard.dismiss();
setEmailError(null);
setPasswordError(null);
// Validate inputs
if (!email.trim()) {
setEmailError('Bitte gib deine E-Mail-Adresse ein');
return;
}
if (!password) {
setPasswordError('Bitte gib dein Passwort ein');
return;
}
// Validate email format
if (!validateEmail(email)) {
setEmailError('Bitte gib eine gültige E-Mail-Adresse ein');
return;
}
setLoading(true);
try {
console.log('Attempting login with email:', email);
const { error } = await signIn(email, password);
if (error) {
console.error('Login error:', error);
// Handle specific error cases with user-friendly messages
if (error.message.includes('Invalid login credentials')) {
setPasswordError('E-Mail oder Passwort ist falsch');
} else if (error.message.includes('Email not confirmed')) {
setEmailError('Deine E-Mail wurde noch nicht bestätigt');
} else if (error.message.includes('rate limit')) {
setEmailError('Zu viele Anmeldeversuche. Bitte versuche es später erneut');
} else if (error.message.includes('network')) {
setEmailError('Netzwerkfehler. Bitte überprüfe deine Internetverbindung');
} else {
setEmailError(error.message || 'Ein unbekannter Fehler ist aufgetreten');
}
} else {
console.log('Login successful');
router.replace('/(tabs)');
}
} catch (e) {
console.error('Unexpected login error:', e);
setEmailError('Ein unerwarteter Fehler ist aufgetreten');
} finally {
setLoading(false);
}
};
// Handle signup
const handleSignUp = async () => {
// Dismiss keyboard
Keyboard.dismiss();
setEmailError(null);
setPasswordError(null);
// Validate inputs
if (!email.trim()) {
setEmailError('Bitte gib deine E-Mail-Adresse ein');
return;
}
if (!password) {
setPasswordError('Bitte gib ein Passwort ein');
return;
}
if (!confirmPassword) {
setPasswordError('Bitte bestätige dein Passwort');
return;
}
// Validate email format
if (!validateEmail(email)) {
setEmailError('Bitte gib eine gültige E-Mail-Adresse ein');
return;
}
if (password !== confirmPassword) {
setPasswordError('Die Passwörter stimmen nicht überein');
return;
}
const passwordValidationError = validatePassword(password);
if (passwordValidationError) {
setPasswordError(passwordValidationError);
return;
}
setLoading(true);
try {
console.log('Attempting signup with email:', email);
const { error } = await signUp(email, password);
if (error) {
console.error('Signup error:', error);
// Handle specific error cases with user-friendly messages
if (error.message.includes('already registered')) {
setEmailError('Diese E-Mail-Adresse wird bereits verwendet');
} else if (error.message.includes('rate limit')) {
setEmailError('Zu viele Registrierungsversuche. Bitte versuche es später erneut');
} else if (error.message.includes('network')) {
setEmailError('Netzwerkfehler. Bitte überprüfe deine Internetverbindung');
} else if (error.message.includes('weak password')) {
setPasswordError('Dein Passwort ist zu schwach. Bitte wähle ein stärkeres Passwort');
} else {
setEmailError(error.message || 'Ein unbekannter Fehler ist aufgetreten');
}
} else {
console.log('Signup successful');
Alert.alert(
'Registrierung erfolgreich',
'Bitte überprüfe deine E-Mail, um dein Konto zu bestätigen.',
[
{
text: 'OK',
onPress: () => {
setMode('login');
setEmail('');
setPassword('');
setConfirmPassword('');
},
},
]
);
}
} catch (e) {
console.error('Unexpected signup error:', e);
setEmailError('Ein unerwarteter Fehler ist aufgetreten');
} finally {
setLoading(false);
}
};
// Dynamic styles that depend on the theme
const dynamicStyles = {
loginButton: {
backgroundColor: '#555', // More neutral color for login button
},
signUpButton: {
backgroundColor: theme.colors.primary,
},
backButton: {
backgroundColor: theme.colors.text + '80', // With opacity
},
};
return (
<View style={styles.mainContainer}>
<ImageBackground
source={require('../../assets/actionfigures/YourCharacter.png')}
style={styles.backgroundImage}
resizeMode="contain"
imageStyle={{ top: 0 }}
>
<KeyboardAwareScrollView
style={styles.container}
contentContainerStyle={styles.scrollContent}
extraScrollHeight={100}
enableOnAndroid
keyboardShouldPersistTaps="handled"
>
{/* Title removed as requested */}
<BlurView intensity={80} tint="dark" style={styles.contentContainer}>
<Text style={styles.subtitle}>
{mode === 'initial' && 'Erstelle ein Konto oder melde dich an'}
{mode === 'login' && !isResetPassword && 'Melde dich mit deinem Konto an'}
{mode === 'login' &&
isResetPassword &&
'Gib deine E-Mail-Adresse ein, um dein Passwort zurückzusetzen'}
{mode === 'register' && 'Erstelle ein neues Konto'}
</Text>
{mode !== 'initial' && (
<View style={styles.inputContainer}>
<TextInput
style={[styles.input, emailError && styles.inputError]}
placeholder="E-Mail"
placeholderTextColor="#888"
value={email}
onChangeText={handleEmailChange}
autoCapitalize="none"
keyboardType="email-address"
testID="email-input"
/>
{emailError && <Text style={styles.errorText}>{emailError}</Text>}
{!isResetPassword && (
<>
<TextInput
style={[styles.input, passwordError && styles.inputError]}
placeholder="Passwort"
placeholderTextColor="#888"
value={password}
onChangeText={handlePasswordChange}
secureTextEntry
testID="password-input"
/>
{passwordError && <Text style={styles.errorText}>{passwordError}</Text>}
{mode === 'register' && (
<>
<TextInput
style={[styles.input, passwordError && styles.inputError]}
placeholder="Passwort bestätigen"
placeholderTextColor="#888"
value={confirmPassword}
onChangeText={handleConfirmPasswordChange}
secureTextEntry
testID="confirm-password-input"
/>
</>
)}
</>
)}
</View>
)}
{mode === 'login' && !isResetPassword && (
<Pressable
style={styles.forgotPasswordContainer}
onPress={() => setIsResetPassword(true)}
>
<Text style={styles.forgotPasswordText}>Passwort vergessen</Text>
<Text style={styles.forgotPasswordArrow}></Text>
</Pressable>
)}
<View style={styles.buttonContainer}>
{mode === 'initial' ? (
<>
<TouchableOpacity
style={[styles.button, dynamicStyles.signUpButton]}
onPress={() => setMode('register')}
>
<View style={styles.buttonContent}>
<Text style={styles.buttonText}>Konto erstellen</Text>
<FontAwesome
name="user-plus"
size={18}
color="#fff"
style={styles.buttonIcon}
/>
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, dynamicStyles.loginButton]}
onPress={() => setMode('login')}
>
<View style={styles.buttonContent}>
<Text style={styles.buttonText}>Anmelden</Text>
<FontAwesome
name="arrow-right"
size={18}
color="#fff"
style={styles.buttonIcon}
/>
</View>
</TouchableOpacity>
</>
) : (
<>
<TouchableOpacity
style={[
styles.button,
mode === 'register'
? dynamicStyles.signUpButton
: mode === 'login' && email && password && !isResetPassword
? dynamicStyles.signUpButton // Highlight when login fields are filled
: dynamicStyles.loginButton,
]}
onPress={
mode === 'register'
? handleSignUp
: isResetPassword
? handleResetPassword
: handleLogin
}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<View style={styles.buttonContent}>
<Text style={styles.buttonText}>
{mode === 'register'
? 'Konto erstellen'
: isResetPassword
? 'Passwort zurücksetzen'
: 'Anmelden'}
</Text>
<FontAwesome
name={
mode === 'register'
? 'user-plus'
: isResetPassword
? 'key'
: 'arrow-right'
}
size={18}
color="#fff"
style={styles.buttonIcon}
/>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, dynamicStyles.backButton]}
onPress={() => {
setMode('initial');
setEmail('');
setPassword('');
setConfirmPassword('');
setEmailError(null);
setPasswordError(null);
setIsResetPassword(false);
}}
>
<View style={styles.buttonContent}>
<Text style={styles.buttonText}>Zurück</Text>
<FontAwesome
name="arrow-left"
size={18}
color="#fff"
style={styles.buttonIcon}
/>
</View>
</TouchableOpacity>
</>
)}
</View>
<Text style={styles.legalText}>
Mit der Anmeldung stimmst du unseren <Text style={styles.legalLink}>AGB</Text> und der{' '}
<Text style={styles.legalLink}>Datenschutzerklärung</Text> zu.
</Text>
</BlurView>
</KeyboardAwareScrollView>
</ImageBackground>
</View>
);
}
const styles = StyleSheet.create({
mainContainer: {
flex: 1,
width: '100%',
alignItems: 'center',
justifyContent: 'flex-start',
backgroundColor: '#000',
},
backgroundImage: {
flex: 1,
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'flex-start',
paddingTop: 0, // Image at the very top
},
container: {
flex: 1,
backgroundColor: 'transparent',
width: '100%',
maxWidth: 480,
aspectRatio: Platform.OS === 'ios' ? undefined : 0.7,
alignSelf: 'center',
marginHorizontal: 'auto',
},
scrollContent: {
flexGrow: 1,
width: '100%',
},
headerContainer: {
marginTop: Platform.OS === 'ios' ? 20 : 30,
paddingHorizontal: Platform.OS === 'ios' ? 20 : 40,
},
contentContainer: {
width: '100%',
padding: Platform.OS === 'ios' ? 16 : 20,
paddingBottom: Platform.OS === 'ios' ? 16 : 8,
marginTop: 'auto',
overflow: 'hidden',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
title: {
fontSize: 36,
fontWeight: 'bold',
marginBottom: 12,
textAlign: 'center',
color: '#fff',
},
subtitle: {
fontSize: 18,
textAlign: 'center',
color: '#fff',
marginBottom: 20,
opacity: 0.9,
},
inputContainer: {
marginBottom: 20,
},
input: {
height: 55,
borderColor: '#333',
borderWidth: 1,
marginBottom: 8,
paddingHorizontal: 15,
borderRadius: 10,
backgroundColor: 'rgba(0, 0, 0, 0.2)',
color: '#fff',
fontSize: 18,
},
inputError: {
borderColor: '#ff4444',
},
errorText: {
color: '#ff4444',
fontSize: 12,
marginBottom: 10,
marginLeft: 5,
},
buttonContainer: {
gap: 15,
},
button: {
height: 50,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
loginButton: {
backgroundColor: '#4a90e2', // Default color, will be overridden
},
signUpButton: {
backgroundColor: '#ff69b4', // Default color, will be overridden
},
backButton: {
backgroundColor: '#555', // Default color, will be overridden
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
buttonContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
buttonIcon: {
marginLeft: 10,
},
legalText: {
color: '#fff',
fontSize: 12,
textAlign: 'center',
marginTop: 20,
marginBottom: 10,
opacity: 0.8,
},
legalLink: {
textDecorationLine: 'underline',
},
forgotPasswordContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
paddingHorizontal: 4,
},
forgotPasswordText: {
color: '#fff',
fontSize: 14,
opacity: 0.9,
},
forgotPasswordArrow: {
color: '#fff',
fontSize: 14,
marginLeft: 8,
opacity: 0.9,
},
});

View file

@ -0,0 +1,276 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
Keyboard,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useAuth } from '../../utils/AuthContext';
import { useTheme } from '../../utils/ThemeContext';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import ErrorMessage from '../../components/ErrorMessage';
export default function Register() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const { signUp } = useAuth();
const router = useRouter();
const { theme, isDark } = useTheme();
// Clear error message when inputs change
const handleInputChange =
(setter: React.Dispatch<React.SetStateAction<string>>) => (text: string) => {
setter(text);
if (errorMessage) setErrorMessage('');
};
const handleRegister = async () => {
// Dismiss keyboard
Keyboard.dismiss();
// Validate inputs
if (!email.trim()) {
setErrorMessage('Bitte gib deine E-Mail-Adresse ein.');
return;
}
if (!password) {
setErrorMessage('Bitte gib ein Passwort ein.');
return;
}
if (!confirmPassword) {
setErrorMessage('Bitte bestätige dein Passwort.');
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
setErrorMessage('Bitte gib eine gültige E-Mail-Adresse ein.');
return;
}
if (password !== confirmPassword) {
setErrorMessage('Die Passwörter stimmen nicht überein.');
return;
}
if (password.length < 6) {
setErrorMessage('Das Passwort muss mindestens 6 Zeichen lang sein.');
return;
}
// Check password strength
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
if (!(hasUpperCase && hasLowerCase && (hasNumbers || hasSpecialChar))) {
setErrorMessage(
'Dein Passwort sollte Groß- und Kleinbuchstaben sowie Zahlen oder Sonderzeichen enthalten.'
);
return;
}
setLoading(true);
setErrorMessage('');
try {
const { error } = await signUp(email, password);
if (error) {
// Handle specific error cases with user-friendly messages
if (error.message.includes('already registered')) {
setErrorMessage(
'Diese E-Mail-Adresse wird bereits verwendet. Bitte wähle eine andere oder melde dich an.'
);
} else if (error.message.includes('rate limit')) {
setErrorMessage('Zu viele Registrierungsversuche. Bitte versuche es später erneut.');
} else if (error.message.includes('network')) {
setErrorMessage('Netzwerkfehler. Bitte überprüfe deine Internetverbindung.');
} else if (error.message.includes('weak password')) {
setErrorMessage('Dein Passwort ist zu schwach. Bitte wähle ein stärkeres Passwort.');
} else {
setErrorMessage(error.message || 'Ein unbekannter Fehler ist aufgetreten.');
}
} else {
Alert.alert(
'Registrierung erfolgreich',
'Bitte überprüfe deine E-Mail, um dein Konto zu bestätigen.',
[{ text: 'OK', onPress: () => router.replace('/login') }]
);
}
} catch (e) {
setErrorMessage('Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es später erneut.');
console.error('Registration error:', e);
} finally {
setLoading(false);
}
};
return (
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
<View style={styles.logoContainer}>
<FontAwesome name="cube" size={80} color={theme.colors.primary} />
<Text style={[styles.title, { color: theme.colors.text }]}>WTFigure</Text>
<Text style={[styles.subtitle, { color: theme.colors.text }]}>
Erstelle und teile deine Action-Figuren
</Text>
</View>
<View style={styles.formContainer}>
<ErrorMessage message={errorMessage} visible={!!errorMessage} />
<TextInput
style={[
styles.input,
{
backgroundColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
color: theme.colors.text,
borderColor: errorMessage && !email ? theme.colors.error : theme.colors.border,
},
]}
placeholder="E-Mail"
placeholderTextColor={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'}
value={email}
onChangeText={handleInputChange(setEmail)}
autoCapitalize="none"
keyboardType="email-address"
testID="email-input"
/>
<TextInput
style={[
styles.input,
{
backgroundColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
color: theme.colors.text,
borderColor: errorMessage && !password ? theme.colors.error : theme.colors.border,
},
]}
placeholder="Passwort"
placeholderTextColor={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'}
value={password}
onChangeText={handleInputChange(setPassword)}
secureTextEntry
testID="password-input"
/>
<TextInput
style={[
styles.input,
{
backgroundColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
color: theme.colors.text,
borderColor:
errorMessage && !confirmPassword ? theme.colors.error : theme.colors.border,
},
]}
placeholder="Passwort bestätigen"
placeholderTextColor={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'}
value={confirmPassword}
onChangeText={handleInputChange(setConfirmPassword)}
secureTextEntry
testID="confirm-password-input"
/>
<Text style={[styles.passwordHint, { color: theme.colors.text + '80' }]}>
Passwort muss mindestens 6 Zeichen lang sein und Groß- und Kleinbuchstaben sowie Zahlen
oder Sonderzeichen enthalten.
</Text>
<TouchableOpacity
style={[styles.button, { backgroundColor: theme.colors.primary }]}
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Registrieren</Text>
)}
</TouchableOpacity>
<View style={styles.footerContainer}>
<Text style={[styles.footerText, { color: theme.colors.text }]}>Bereits ein Konto?</Text>
<TouchableOpacity onPress={() => router.push('/login')}>
<Text style={[styles.footerLink, { color: theme.colors.primary }]}>Anmelden</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
passwordHint: {
fontSize: 12,
marginBottom: 15,
marginTop: -5,
},
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
},
logoContainer: {
alignItems: 'center',
marginBottom: 40,
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginTop: 10,
},
subtitle: {
fontSize: 16,
marginTop: 5,
textAlign: 'center',
},
formContainer: {
width: '100%',
maxWidth: 400,
alignSelf: 'center',
},
input: {
height: 50,
borderWidth: 1,
borderRadius: 10,
marginBottom: 15,
paddingHorizontal: 15,
fontSize: 16,
},
button: {
height: 50,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
marginTop: 10,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
footerContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 20,
},
footerText: {
marginRight: 5,
},
footerLink: {
fontWeight: 'bold',
},
});

View file

@ -0,0 +1,87 @@
import { Link, Tabs } from 'expo-router';
import { useTheme } from '../../utils/ThemeContext';
import { Platform, StyleSheet } from 'react-native';
import { BlurView } from 'expo-blur';
import { Header } from '../../components/Header';
import { TabBarIcon } from '../../components/TabBarIcon';
export default function TabLayout() {
const { theme, isDark } = useTheme();
// Bestimme die Intensität des Blur-Effekts basierend auf dem Theme
const blurIntensity = isDark ? 80 : 60;
const blurTint = isDark ? 'dark' : 'light';
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)',
tabBarStyle: {
position: 'absolute',
backgroundColor:
Platform.OS === 'ios'
? 'transparent'
: isDark
? 'rgba(30,30,30,0.7)'
: 'rgba(255,255,255,0.7)',
borderTopColor: 'transparent',
elevation: 0,
height: 60,
},
tabBarBackground: () =>
Platform.OS === 'ios' ? (
<BlurView tint={blurTint} intensity={blurIntensity} style={StyleSheet.absoluteFill} />
) : null,
headerTransparent: true,
headerStyle: {
backgroundColor: 'transparent',
},
headerBackground: () => (
<BlurView tint={blurTint} intensity={blurIntensity} style={StyleSheet.absoluteFill} />
),
headerTintColor: theme.colors.text,
headerShadowVisible: false,
headerTitleStyle: {
fontWeight: '600',
},
// Hide the header completely
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Community',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="community" color={color} focused={focused} />
),
headerRight: () => (
<Link href="/settings" asChild>
<Header standalone={true} />
</Link>
),
}}
/>
<Tabs.Screen
name="create"
options={{
title: 'Create',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="create" color={color} focused={focused} />
),
}}
/>
<Tabs.Screen
name="shelf"
options={{
title: 'Collection',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="shelf" color={color} focused={focused} />
),
}}
/>
</Tabs>
);
}

View file

@ -0,0 +1,156 @@
import { Stack } from 'expo-router';
import {
StyleSheet,
View,
Image,
Text,
TouchableOpacity,
ActivityIndicator,
ScrollView,
useWindowDimensions,
} from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import React, { useRef, useState } from 'react';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { useTheme } from '~/utils/ThemeContext';
import SidebarCreateFigureForm from '~/components/CreateFigureForm';
export default function Create() {
const { theme } = useTheme();
const { height } = useWindowDimensions();
const [isGenerating, setIsGenerating] = useState(false);
const handleSubmitRef = useRef<() => Promise<void>>();
// Function to handle the form submission from the sticky button
const handleStickySubmit = async () => {
if (handleSubmitRef.current) {
setIsGenerating(true);
try {
await handleSubmitRef.current();
} finally {
setIsGenerating(false);
}
}
};
// Callback to receive the handleSubmit function from the form component
const onFormSubmit = (handleSubmit: () => Promise<void>) => {
handleSubmitRef.current = handleSubmit;
};
return (
<>
<Stack.Screen
options={{
title: 'Create',
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTintColor: theme.colors.text,
headerShadowVisible: false,
}}
/>
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
<Image
source={require('../../assets/actionfigures/YourCharacter.png')}
style={styles.backgroundImage}
resizeMode="contain"
/>
<KeyboardAwareScrollView
style={styles.scrollContainer}
contentContainerStyle={[styles.scrollContentContainer, { paddingTop: height * 0.65 }]}
showsVerticalScrollIndicator={false}
bounces={false}
extraScrollHeight={100}
enableOnAndroid
keyboardShouldPersistTaps="handled"
enableResetScrollToCoords={false}
>
<View style={styles.formContainer}>
<SidebarCreateFigureForm onSubmit={onFormSubmit} />
</View>
</KeyboardAwareScrollView>
{/* Sticky Create Figure Button */}
<TouchableOpacity
style={[styles.stickyButton, { backgroundColor: theme.colors.primary }]}
onPress={handleStickySubmit}
disabled={isGenerating}
>
{isGenerating ? (
<ActivityIndicator color="#fff" size="small" style={{ marginRight: 10 }} />
) : null}
<Text style={styles.buttonText}>{isGenerating ? 'Creating...' : 'Create Figgo'}</Text>
{!isGenerating && (
<FontAwesome name="arrow-right" size={16} color="#fff" style={{ marginLeft: 10 }} />
)}
</TouchableOpacity>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
padding: 0,
margin: 0,
position: 'relative',
},
backgroundImage: {
width: '100%',
height: '100%',
maxWidth: 800,
maxHeight: 800,
resizeMode: 'contain',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
marginLeft: 'auto',
marginRight: 'auto',
zIndex: 1,
opacity: 0.9,
},
scrollContainer: {
flex: 1,
width: '100%',
zIndex: 2,
},
scrollContentContainer: {
flexGrow: 1,
paddingBottom: 120, // Add padding at the bottom for the sticky button
},
formContainer: {
width: '100%',
zIndex: 2,
paddingBottom: 0, // Remove bottom padding to avoid extra space
maxWidth: 450, // Further reduced max width for the form
alignSelf: 'center', // Center the form
},
stickyButton: {
position: 'absolute',
bottom: 70, // Positioned lower, closer to the tab bar
left: '50%', // Center horizontally
transform: [{ translateX: -100 }], // Half of the width to center
width: 200, // Fixed width for a more compact button
height: 50, // Reduced height
borderRadius: 25, // Half of height for rounded corners
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999, // Much higher z-index to ensure it's above the tab bar
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 10, // Higher elevation for Android
},
buttonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
});

View file

@ -0,0 +1,278 @@
import { Stack, router } from 'expo-router';
import {
StyleSheet,
View,
ScrollView,
SafeAreaView,
ActivityIndicator,
useWindowDimensions,
Animated,
TouchableOpacity,
} from 'react-native';
import React, { useRef, useState as useReactState } from 'react';
import { useScrollToTop } from '@react-navigation/native';
import { useTheme } from '~/utils/ThemeContext';
import { ThemedView, ThemedText } from '~/components/ThemedView';
import { useState, useEffect, useMemo } from 'react';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { getPublicFigures } from '~/utils/figureService';
import { VerticalFigureCard } from '~/components/FigureCard';
import { Header } from '~/components/Header';
// Definiere den Typ für eine Figur
interface Figure {
id: string;
name: string;
subject: string;
image_url: string;
theme?: string;
rarity?: string;
likes?: number;
user_id?: string;
character_info?: {
character?: {
description?: string;
};
items?: Array<{
name?: string;
description?: string;
}>;
};
}
export default function Home() {
const { theme, isDark } = useTheme();
const { width } = useWindowDimensions();
const [figures, setFigures] = useState<Figure[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Create a ref for the ScrollView
const scrollViewRef = useRef<ScrollView>(null);
useScrollToTop(scrollViewRef);
// Animation für das Settings-Icon
const scrollY = useRef(new Animated.Value(0)).current;
const settingsOpacity = scrollY.interpolate({
inputRange: [0, 50],
outputRange: [1, 0],
extrapolate: 'clamp',
});
const settingsTranslateY = scrollY.interpolate({
inputRange: [0, 50],
outputRange: [0, -50],
extrapolate: 'clamp',
});
// Bestimme die Anzahl der Spalten basierend auf der Bildschirmbreite
const columns = useMemo(() => {
if (width >= 1200) return 3; // Drei Spalten für sehr breite Bildschirme (Desktop)
if (width >= 768) return 2; // Zwei Spalten für mittlere Bildschirme (Tablets)
return 1; // Eine Spalte für schmale Bildschirme (Smartphones)
}, [width]);
// Lade öffentliche Figuren aus der Datenbank
useEffect(() => {
async function loadPublicFigures() {
setLoading(true);
try {
const data = await getPublicFigures();
console.log('Public figures data:', data);
setFigures(data);
} catch (err: any) {
console.error('Fehler beim Laden der öffentlichen Figuren:', err);
setError(err?.message || 'Unbekannter Fehler');
} finally {
setLoading(false);
}
}
loadPublicFigures();
}, []);
return (
<>
<Stack.Screen
options={{
title: 'Community',
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTintColor: theme.colors.text,
headerShadowVisible: false,
}}
/>
<ThemedView
style={[styles.container, { backgroundColor: theme.colors.background }]}
debugBorderType="primary"
>
{/* Header mit App-Icon, Titel und Settings-Icon */}
<Header title="Figgo's Feed" scrollY={scrollY} />
{error ? (
<ThemedView style={styles.errorContainer} debugBorderType="tertiary">
<ThemedText style={styles.errorText}>Fehler beim Laden der Figuren: {error}</ThemedText>
</ThemedView>
) : figures.length === 0 && !loading ? (
<ThemedView style={styles.emptyContainer} debugBorderType="tertiary">
<ThemedText style={styles.emptyText}>Keine öffentlichen Figuren gefunden.</ThemedText>
</ThemedView>
) : (
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
})}
scrollEventThrottle={16}
>
<ThemedView style={[styles.cardsContainer]} debugBorderType="secondary">
<View style={[styles.cardsGrid, { flexDirection: columns === 1 ? 'column' : 'row' }]}>
{loading
? // Platzhalter-Karten während des Ladens anzeigen
Array.from({ length: 6 }).map((_, index) => (
<View
key={`placeholder-${index}`}
style={[
styles.cardWrapper,
{
width: columns === 1 ? '100%' : columns === 2 ? '50%' : '33.33%',
paddingHorizontal: columns > 1 ? 8 : 0,
},
]}
>
<VerticalFigureCard
image={{ uri: 'https://via.placeholder.com/1x1.png' }} // Leeres 1x1 Pixel Bild statt YourCharacter.png
title="Wird geladen..."
creator=""
likes={0}
characterInfo={undefined}
/>
</View>
))
: // Tatsächliche Figuren anzeigen, wenn sie geladen sind
figures.map((figure, index) => (
<View
key={figure.id}
style={[
styles.cardWrapper,
{
width: columns === 1 ? '100%' : columns === 2 ? '50%' : '33.33%',
paddingHorizontal: columns > 1 ? 8 : 0,
},
]}
>
<VerticalFigureCard
image={
figure.image_url
? { uri: figure.image_url }
: {
uri: 'https://via.placeholder.com/300x450/333333/666666?text=Kein+Bild',
}
}
title={figure.name}
creator={figure.subject}
likes={figure.likes || 0}
characterInfo={figure.character_info}
/>
</View>
))}
</View>
</ThemedView>
</ScrollView>
)}
</ThemedView>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
overflow: 'visible', // Erlaubt Elementen, über den Container hinauszuragen
},
// Styles für Settings-Icon wurden in die Header-Komponente verschoben
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
emptyText: {
fontSize: 16,
textAlign: 'center',
},
scrollContent: {
paddingTop: 100, // Abstand oben für den Header
paddingBottom: 20, // Abstand unten
paddingHorizontal: 0, // Horizontalen Abstand entfernt
overflow: 'visible', // Erlaubt Elementen, über den Container hinauszuragen
},
cardsContainer: {
width: '100%',
alignItems: 'center',
overflow: 'visible', // Erlaubt Elementen, über den Container hinauszuragen
paddingHorizontal: 0, // Kein Padding für die äußeren Ränder
},
cardsGrid: {
width: '100%',
flexWrap: 'wrap',
justifyContent: 'flex-start',
alignItems: 'flex-start',
},
cardWrapper: {
marginBottom: 20,
},
card: {
width: '100%',
borderRadius: 10,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
imageContainer: {
width: '100%',
height: 300,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
},
image: {
width: '100%',
height: '100%',
},
infoContainer: {
padding: 15,
},
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 5,
},
creator: {
fontSize: 14,
marginBottom: 5,
opacity: 0.7,
},
likes: {
fontSize: 12,
opacity: 0.6,
},
});

View file

@ -0,0 +1,207 @@
import { Stack } from 'expo-router';
import { StyleSheet, View, ScrollView, ActivityIndicator, useWindowDimensions } from 'react-native';
import React, { useRef } from 'react';
import { useScrollToTop } from '@react-navigation/native';
import { useTheme } from '~/utils/ThemeContext';
import { ThemedView, ThemedText } from '~/components/ThemedView';
import { useState, useEffect, useMemo } from 'react';
import { useAuth } from '~/utils/AuthContext';
import { getUserFigures } from '~/utils/figureService';
import { VerticalFigureCard } from '~/components/FigureCard';
// Definiere den Typ für eine Figur
interface Figure {
id: string;
name: string;
subject: string;
image_url: string;
theme?: string;
rarity?: string;
likes?: number;
user_id?: string;
character_info?: {
character?: {
description?: string;
lore?: string;
};
items?: Array<{
name?: string;
description?: string;
lore?: string;
}>;
};
}
export default function Shelf() {
const { theme, isDark } = useTheme();
const { user } = useAuth();
const { width } = useWindowDimensions();
const [figures, setFigures] = useState<Figure[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Create a ref for the ScrollView
const scrollViewRef = useRef<ScrollView>(null);
useScrollToTop(scrollViewRef);
// Bestimme die Anzahl der Spalten basierend auf der Bildschirmbreite
const columns = useMemo(() => {
if (width >= 1200) return 3; // Drei Spalten für sehr breite Bildschirme (Desktop)
if (width >= 768) return 2; // Zwei Spalten für mittlere Bildschirme (Tablets)
return 1; // Eine Spalte für schmale Bildschirme (Smartphones)
}, [width]);
// Lade die Figuren des Nutzers aus der Datenbank
useEffect(() => {
async function loadUserFigures() {
if (!user) {
setError('Du musst angemeldet sein, um deine Figuren zu sehen.');
setLoading(false);
return;
}
setLoading(true);
try {
const data = await getUserFigures(user.id);
console.log('User figures data:', data);
setFigures(data);
} catch (err: any) {
console.error('Fehler beim Laden der Figuren des Nutzers:', err);
setError(err?.message || 'Unbekannter Fehler');
} finally {
setLoading(false);
}
}
loadUserFigures();
}, [user]);
return (
<>
<Stack.Screen
options={{
title: 'Collection',
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTintColor: theme.colors.text,
headerShadowVisible: false,
}}
/>
<ThemedView
style={[styles.container, { backgroundColor: theme.colors.background }]}
debugBorderType="primary"
>
{loading ? (
<ThemedView style={styles.loadingContainer} debugBorderType="tertiary">
<ActivityIndicator size="large" color={theme.colors.primary} />
<ThemedText style={styles.loadingText}>Lade deine Figuren...</ThemedText>
</ThemedView>
) : error ? (
<ThemedView style={styles.errorContainer} debugBorderType="tertiary">
<ThemedText style={styles.errorText}>
Fehler beim Laden deiner Figuren: {error}
</ThemedText>
</ThemedView>
) : figures.length === 0 ? (
<ThemedView style={styles.emptyContainer} debugBorderType="tertiary">
<ThemedText style={styles.emptyText}>Du hast noch keine Figuren erstellt.</ThemedText>
</ThemedView>
) : (
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<ThemedView style={[styles.cardsContainer]} debugBorderType="secondary">
<View style={[styles.cardsGrid, { flexDirection: columns === 1 ? 'column' : 'row' }]}>
{figures.map((figure, index) => (
<View
key={figure.id}
style={[
styles.cardWrapper,
{
width: columns === 1 ? '100%' : columns === 2 ? '50%' : '33.33%',
paddingHorizontal: columns > 1 ? 8 : 0,
},
]}
>
<VerticalFigureCard
image={
figure.image_url
? { uri: figure.image_url }
: require('../../assets/actionfigures/YourCharacter.png')
}
title={figure.name}
creator={figure.subject}
likes={figure.likes || 0}
characterInfo={figure.character_info}
/>
</View>
))}
</View>
</ThemedView>
</ScrollView>
)}
</ThemedView>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
overflow: 'visible', // Erlaubt Elementen, über den Container hinauszuragen
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
emptyText: {
fontSize: 18,
textAlign: 'center',
},
scrollContent: {
paddingTop: 100, // Abstand oben für den Header
paddingBottom: 20, // Abstand unten
paddingHorizontal: 0, // Horizontalen Abstand entfernt
overflow: 'visible', // Erlaubt Elementen, über den Container hinauszuragen
},
cardsContainer: {
width: '100%',
alignItems: 'center',
overflow: 'visible', // Erlaubt Elementen, über den Container hinauszuragen
paddingHorizontal: 8, // Padding für die äußeren Ränder
},
cardsGrid: {
width: '100%',
flexWrap: 'wrap',
justifyContent: 'flex-start',
alignItems: 'flex-start',
},
cardWrapper: {
marginBottom: 20,
},
});

View file

@ -0,0 +1,46 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View file

@ -0,0 +1,23 @@
import { Link, Stack } from 'expo-router';
import { Text, View } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View className={styles.container}>
<Text className={styles.title}>This screen doesn't exist.</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = {
container: `items-center flex-1 justify-center p-5`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View file

@ -0,0 +1,64 @@
import '../global.css';
import { Stack, useRouter, useSegments } from 'expo-router';
import { ThemeProvider } from '../utils/ThemeContext';
import { AuthProvider, useAuth } from '../utils/AuthContext';
import { useEffect } from 'react';
import ErrorBoundary, { setupGlobalErrorHandler } from '../utils/ErrorHandler';
// Globalen Fehlerhandler einrichten
setupGlobalErrorHandler();
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
// Authentifizierungsprüfung
function AuthenticationGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (loading) return; // Warte, bis der Auth-Status geladen ist
const inAuthGroup = segments[0] === '(auth)';
// Wenn der Benutzer nicht angemeldet ist und nicht im Auth-Bereich
if (!user && !inAuthGroup) {
router.replace('/login');
}
// Wenn der Benutzer angemeldet ist und im Auth-Bereich
else if (user && inAuthGroup) {
router.replace('/(tabs)');
}
}, [user, loading, segments]);
if (loading) {
// Hier könnte ein Ladeindikator angezeigt werden
return null;
}
return <>{children}</>;
}
export default function RootLayout() {
return (
<ErrorBoundary>
<ThemeProvider>
<AuthProvider>
<AuthenticationGuard>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
<Stack.Screen name="subscription" options={{ title: 'Mana' }} />
</Stack>
</AuthenticationGuard>
</AuthProvider>
</ThemeProvider>
</ErrorBoundary>
);
}

View file

@ -0,0 +1,13 @@
import { StatusBar } from 'expo-status-bar';
import { Platform } from 'react-native';
import { ScreenContent } from '~/components/ScreenContent';
export default function Modal() {
return (
<>
<ScreenContent path="app/modal.tsx" title="Modal" />
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</>
);
}

View file

@ -0,0 +1,371 @@
import { Stack, useRouter } from 'expo-router';
import { StyleSheet, View, Text, TouchableOpacity, ScrollView, Alert, Switch } from 'react-native';
import { useTheme } from '~/utils/ThemeContext';
import { ThemeName, ThemeMode } from '~/utils/themes';
import { useAuth } from '~/utils/AuthContext';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { ThemedView, ThemedText } from '~/components/ThemedView';
export default function Settings() {
const {
themeName,
themeMode,
setThemeName,
setThemeMode,
theme,
isDark,
debugBorders,
setDebugBorders,
} = useTheme();
const { signOut, user } = useAuth();
const router = useRouter();
// Theme options
const themeOptions: ThemeName[] = ['default', 'pastel', 'vibrant'];
const themeModeOptions: ThemeMode[] = ['system', 'light', 'dark'];
// Theme option labels
const themeLabels: Record<ThemeName, string> = {
default: 'Default',
pastel: 'Pastel',
vibrant: 'Vibrant',
};
const themeModeLabels: Record<ThemeMode, string> = {
system: 'System',
light: 'Light',
dark: 'Dark',
};
const handleSignOut = async () => {
Alert.alert('Abmelden', 'Möchtest du dich wirklich abmelden?', [
{
text: 'Abbrechen',
style: 'cancel',
},
{
text: 'Abmelden',
onPress: async () => {
try {
console.log('Logging out...');
const { error } = await signOut();
if (error) {
console.error('Logout error:', error);
Alert.alert('Fehler', 'Es gab ein Problem beim Abmelden: ' + error.message);
} else {
console.log('Logout successful, navigating to login screen');
// Navigate to login screen after successful logout
router.replace('/login');
}
} catch (e) {
console.error('Unexpected error during logout:', e);
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
}
},
},
]);
};
return (
<>
<Stack.Screen
options={{
title: 'Settings',
headerStyle: {
backgroundColor: theme.colors.card,
},
headerTintColor: theme.colors.text,
headerShadowVisible: false,
}}
/>
<ScrollView
style={[styles.container, { backgroundColor: theme.colors.background }]}
contentContainerStyle={styles.contentContainer}
>
<ThemedText style={styles.sectionTitle}>Appearance</ThemedText>
{/* Theme Selection */}
<ThemedView style={styles.section} debugBorderType="primary">
<ThemedText style={styles.sectionHeader}>Theme</ThemedText>
<ThemedView style={styles.optionsContainer} debugBorderType="secondary">
{themeOptions.map((option) => (
<TouchableOpacity
key={option}
style={[
styles.optionButton,
{
backgroundColor:
themeName === option ? theme.colors.primary : theme.colors.card,
borderColor: theme.colors.border,
},
]}
onPress={() => setThemeName(option)}
>
<Text
style={[
styles.optionText,
{ color: themeName === option ? 'white' : theme.colors.text },
]}
>
{themeLabels[option]}
</Text>
</TouchableOpacity>
))}
</ThemedView>
</ThemedView>
{/* Mode Selection */}
<ThemedView style={styles.section} debugBorderType="primary">
<ThemedText style={styles.sectionHeader}>Mode</ThemedText>
<ThemedView style={styles.optionsContainer} debugBorderType="secondary">
{themeModeOptions.map((option) => (
<TouchableOpacity
key={option}
style={[
styles.optionButton,
{
backgroundColor:
themeMode === option ? theme.colors.primary : theme.colors.card,
borderColor: theme.colors.border,
},
]}
onPress={() => setThemeMode(option)}
>
<Text
style={[
styles.optionText,
{ color: themeMode === option ? 'white' : theme.colors.text },
]}
>
{themeModeLabels[option]}
</Text>
</TouchableOpacity>
))}
</ThemedView>
</ThemedView>
{/* Theme Preview */}
<ThemedView style={styles.section} debugBorderType="primary">
<ThemedText style={styles.sectionHeader}>Preview</ThemedText>
<ThemedView
style={[
styles.previewCard,
{ backgroundColor: theme.colors.card, borderColor: theme.colors.border },
]}
debugBorderType="tertiary"
>
<ThemedText style={styles.previewTitle}>
Current Theme: {themeLabels[themeName]}
</ThemedText>
<ThemedText style={styles.previewSubtitle}>
Mode: {themeModeLabels[themeMode]}
</ThemedText>
<ThemedView style={styles.colorSamples} debugBorderType="quaternary">
<View style={[styles.colorSample, { backgroundColor: theme.colors.primary }]} />
<View style={[styles.colorSample, { backgroundColor: theme.colors.secondary }]} />
<View style={[styles.colorSample, { backgroundColor: theme.colors.accent }]} />
</ThemedView>
<TouchableOpacity
style={[styles.previewButton, { backgroundColor: theme.colors.primary }]}
>
<Text style={styles.previewButtonText}>Button</Text>
</TouchableOpacity>
</ThemedView>
</ThemedView>
{/* Developer Options Section */}
<ThemedText style={[styles.sectionTitle, { marginTop: 20 }]}>Entwickleroptionen</ThemedText>
<ThemedView style={styles.section} debugBorderType="primary">
<ThemedView
style={[
styles.settingRow,
{ backgroundColor: theme.colors.card, borderColor: theme.colors.border },
]}
debugBorderType="secondary"
>
<ThemedView style={styles.settingLabelContainer} debugBorderType="tertiary">
<FontAwesome
name="bug"
size={16}
color={theme.colors.text}
style={styles.settingIcon}
/>
<ThemedText style={styles.settingLabel}>Debug Borders anzeigen</ThemedText>
</ThemedView>
<Switch
value={debugBorders}
onValueChange={setDebugBorders}
trackColor={{ false: '#767577', true: theme.colors.primary }}
thumbColor="#f4f3f4"
/>
</ThemedView>
<ThemedText style={styles.settingDescription}>
Zeigt Rahmen um UI-Elemente an, um das Layout zu debuggen
</ThemedText>
</ThemedView>
{/* Account Section */}
{user && (
<>
<ThemedText style={[styles.sectionTitle, { marginTop: 20 }]}>Account</ThemedText>
<ThemedView style={styles.section} debugBorderType="primary">
<ThemedView
style={[
styles.accountInfo,
{ backgroundColor: theme.colors.card, borderColor: theme.colors.border },
]}
debugBorderType="secondary"
>
<ThemedView style={styles.accountInfoRow} debugBorderType="tertiary">
<FontAwesome name="envelope" size={16} color={theme.colors.text} />
<ThemedText style={styles.accountInfoText}>{user.email}</ThemedText>
</ThemedView>
</ThemedView>
<TouchableOpacity
style={[styles.logoutButton, { backgroundColor: '#ff3b30' }]}
onPress={handleSignOut}
>
<FontAwesome name="sign-out" size={18} color="#fff" style={styles.buttonIcon} />
<Text style={styles.logoutButtonText}>Abmelden</Text>
</TouchableOpacity>
</ThemedView>
</>
)}
</ScrollView>
</>
);
}
const styles = StyleSheet.create({
settingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderRadius: 12,
borderWidth: 1,
marginBottom: 8,
},
settingLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
},
settingLabel: {
fontSize: 16,
fontWeight: '500',
},
settingDescription: {
fontSize: 14,
marginLeft: 4,
marginBottom: 16,
opacity: 0.7,
},
settingIcon: {
marginRight: 12,
},
accountInfo: {
padding: 16,
borderRadius: 12,
borderWidth: 1,
marginBottom: 16,
},
accountInfoRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 4,
},
accountInfoText: {
fontSize: 16,
marginLeft: 12,
},
logoutButton: {
flexDirection: 'row',
height: 50,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
logoutButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
buttonIcon: {
marginRight: 10,
},
container: {
flex: 1,
},
contentContainer: {
padding: 24,
paddingBottom: 40,
},
sectionTitle: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 16,
},
section: {
marginBottom: 24,
},
sectionHeader: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
},
optionsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -6,
},
optionButton: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 8,
borderWidth: 1,
marginHorizontal: 6,
marginBottom: 12,
minWidth: 100,
alignItems: 'center',
},
optionText: {
fontSize: 16,
fontWeight: '500',
},
previewCard: {
padding: 16,
borderRadius: 12,
borderWidth: 1,
},
previewTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 8,
},
previewSubtitle: {
fontSize: 14,
marginBottom: 16,
},
colorSamples: {
flexDirection: 'row',
marginBottom: 16,
},
colorSample: {
width: 30,
height: 30,
borderRadius: 15,
marginRight: 8,
},
previewButton: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
},
previewButtonText: {
color: 'white',
fontWeight: '600',
},
});

View file

@ -0,0 +1,34 @@
import React, { useState } from 'react';
import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { Stack, useRouter } from 'expo-router';
import { useTheme } from '../utils/ThemeContext';
import { Button } from '../components/Button';
import { supabase } from '../utils/supabase';
import SubscriptionPage from '../components/subscription/SubscriptionPage';
function SubscriptionScreen() {
const { theme } = useTheme();
return (
<View className="flex-1 bg-[#121212]">
<StatusBar style="light" />
<Stack.Screen
options={{
title: 'Mana',
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTintColor: theme.colors.text,
headerTitleStyle: {
fontWeight: 'bold',
},
}}
/>
<SubscriptionPage />
</View>
);
}
export default SubscriptionScreen;

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,32 @@
import React from 'react';
import Svg, { Circle } from 'react-native-svg';
import { ViewStyle } from 'react-native';
interface MoreVerticalIconProps {
size?: number;
color?: string;
style?: ViewStyle;
}
export const MoreVerticalIcon: React.FC<MoreVerticalIconProps> = ({
size = 24,
color = '#000',
style,
}) => {
const circleRadius = size / 10;
const centerX = size / 2;
const spacing = size / 3;
return (
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={style}>
{/* Oberer Kreis */}
<Circle cx={centerX} cy={spacing} r={circleRadius} fill={color} />
{/* Mittlerer Kreis */}
<Circle cx={centerX} cy={spacing * 2} r={circleRadius} fill={color} />
{/* Unterer Kreis */}
<Circle cx={centerX} cy={spacing * 3} r={circleRadius} fill={color} />
</Svg>
);
};

View file

@ -0,0 +1 @@
export * from './MoreVerticalIcon';

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

View file

@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true);
const plugins = [];
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

View file

@ -0,0 +1,40 @@
{
"cesVersion": "2.14.3",
"projectName": "wtfigure",
"packages": [
{
"name": "expo-router",
"type": "navigation",
"options": {
"type": "tabs"
}
},
{
"name": "nativewind",
"type": "styling"
},
{
"name": "supabase",
"type": "authentication"
}
],
"flags": {
"noGit": false,
"noInstall": false,
"overwrite": false,
"importAlias": true,
"packageManager": "npm",
"eas": true,
"publish": false
},
"packageManager": {
"type": "npm",
"version": "10.7.0"
},
"os": {
"type": "Darwin",
"platform": "darwin",
"arch": "arm64",
"kernelVersion": "24.1.0"
}
}

View file

@ -0,0 +1,45 @@
import { forwardRef } from 'react';
import { Text, Pressable, View } from 'react-native';
import { useState } from 'react';
import { useTheme } from '~/utils/ThemeContext';
type ButtonProps = {
title: string;
onPress?: () => void;
disabled?: boolean;
className?: string;
style?: any;
};
export const Button = forwardRef<View, ButtonProps>(
({ title, onPress, disabled, className, style, ...props }, ref) => {
const [isHovered, setIsHovered] = useState(false);
const { theme } = useTheme();
return (
<Pressable
ref={ref}
onPress={onPress}
disabled={disabled}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
className={`items-center rounded-[28px] shadow-md p-4 transition-colors ${isHovered ? 'opacity-90' : ''} ${disabled ? 'opacity-70' : ''} ${className || ''}`}
style={[
{
backgroundColor: disabled
? '#9ca3af'
: isHovered
? theme.colors.secondary
: theme.colors.primary,
},
style,
]}
{...props}
>
<Text className="text-white text-lg font-semibold text-center">{title}</Text>
</Pressable>
);
}
);
// Styles werden jetzt direkt im Component mit theme.colors verwendet

View file

@ -0,0 +1,520 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
Image,
TouchableOpacity,
Alert,
useWindowDimensions,
Modal,
ImageBackground,
Platform,
ActivityIndicator,
Switch,
} from 'react-native';
// KeyboardAwareScrollView entfernt - Scrolling wird jetzt in der Parent-Komponente implementiert
import { BlurView } from 'expo-blur';
import * as ImagePicker from 'expo-image-picker';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { useTheme } from '~/utils/ThemeContext';
import { generateFigure } from '~/utils/figureService';
import { useAuth } from '~/utils/AuthContext';
import { router } from 'expo-router';
// FigureData Typ-Definition
interface FigureData {
name: string;
characterDescription: string;
characterImage: string | null;
artifacts: Array<{
name?: string; // Neues Feld für den Namen des Artefakts
description: string;
image: string | null;
}>;
}
// Erweiterte FigureData mit Rarität
export type ExtendedFigureData = FigureData & {
rarity?: string; // Rarität der Figur (z.B. 'common', 'rare', 'legendary')
};
interface ArtifactData {
name?: string; // Neues Feld für den Namen des Artefakts
description: string;
image: string | null;
}
interface SidebarCreateFigureFormProps {
onSubmit?: (handleSubmit: () => Promise<void>) => void;
}
export const SidebarCreateFigureForm: React.FC<SidebarCreateFigureFormProps> = ({ onSubmit }) => {
const { theme, isDark } = useTheme();
const { width, height } = useWindowDimensions();
const { user } = useAuth();
// Determine if we're in wide screen mode (tablet/desktop)
const isWideScreen = width > 768;
// Calculate half of the screen height for initial scroll distance
const halfScreenHeight = height / 2;
// Check if we're running in a web environment
const isWeb = Platform.OS === 'web';
// State for tracking which input is focused
const [focusedInput, setFocusedInput] = useState<
'character' | 'name' | `artifact-${number}` | null
>(null);
// State für den Generierungsprozess
const [isGenerating, setIsGenerating] = useState(false);
// State für die Sichtbarkeit der Figur
const [isPublic, setIsPublic] = useState(true);
// Initialize form state with empty values - only name is required
const [formData, setFormData] = useState<ExtendedFigureData>({
name: '',
characterDescription: '',
characterImage: null,
artifacts: [
{ description: '', image: null },
{ description: '', image: null },
{ description: '', image: null },
],
});
// State for description modals
const [descriptionModalVisible, setDescriptionModalVisible] = useState(false);
const [currentDescriptionType, setCurrentDescriptionType] = useState<'character' | 'artifact'>(
'character'
);
const [currentArtifactIndex, setCurrentArtifactIndex] = useState(0);
// Handle name change
const handleNameChange = (text: string) => {
setFormData({
...formData,
name: text,
});
};
// Handle character description change
const handleCharacterDescriptionChange = (text: string) => {
setFormData({
...formData,
characterDescription: text,
});
};
// Handle artifact description change
const handleArtifactDescriptionChange = (index: number, text: string) => {
const updatedArtifacts = [...formData.artifacts];
updatedArtifacts[index] = {
...updatedArtifacts[index],
description: text,
};
setFormData({
...formData,
artifacts: updatedArtifacts,
});
};
// Pick an image from the gallery
const pickImage = async (type: 'character' | number) => {
try {
// Berechtigungen prüfen
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Berechtigung verweigert',
'Wir benötigen Zugriff auf deine Fotos, um ein Bild auszuwählen.'
);
return;
}
// Bild auswählen
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const selectedImage = result.assets[0].uri;
// Update state based on type
if (type === 'character') {
setFormData({
...formData,
characterImage: selectedImage,
});
} else if (typeof type === 'number') {
const updatedArtifacts = [...formData.artifacts];
updatedArtifacts[type] = {
...updatedArtifacts[type],
image: selectedImage,
};
setFormData({
...formData,
artifacts: updatedArtifacts,
});
}
}
} catch (error) {
console.error('Error picking image:', error);
Alert.alert('Fehler', 'Es gab ein Problem beim Auswählen des Bildes.');
}
};
// Handle form submission
const handleSubmit = async () => {
// Validate form - only name is required
if (!formData.name.trim()) {
Alert.alert('Error', 'Please enter a name for your figure.');
return;
}
// All other fields are optional and will be generated by the LLM if missing
if (!user) {
Alert.alert('Fehler', 'Du musst eingeloggt sein, um eine Figur zu erstellen.');
return;
}
try {
setIsGenerating(true);
// Figur generieren
const figure = await generateFigure(formData, isPublic);
// Erfolgsmeldung anzeigen
Alert.alert('Success!', 'Your action figure has been created!', [
{
text: 'Go to My Shelf',
onPress: () => router.replace('/(tabs)'),
},
{
text: 'OK',
style: 'cancel',
},
]);
// Formular zurücksetzen
setFormData({
name: '',
characterDescription: '',
characterImage: null,
artifacts: [
{ description: '', image: null },
{ description: '', image: null },
{ description: '', image: null },
],
});
} catch (error) {
console.error('Error creating figure:', error);
Alert.alert('Error', 'There was a problem creating your figure. Please try again.');
} finally {
setIsGenerating(false);
}
};
// Render description modal
const renderDescriptionModal = () => {
const isCharacter = currentDescriptionType === 'character';
const currentDescription = isCharacter
? formData.characterDescription
: formData.artifacts[currentArtifactIndex].description;
return (
<Modal
animationType="slide"
transparent={true}
visible={descriptionModalVisible}
onRequestClose={() => setDescriptionModalVisible(false)}
>
<View className="flex-1 justify-center items-center bg-black/50 p-5">
<View
className="w-[90%] max-w-[500px] rounded-[15px] p-5"
style={{ backgroundColor: theme.colors.card }}
>
<Text className="text-base font-bold mb-4" style={{ color: theme.colors.text }}>
{isCharacter
? 'Character Description'
: `Artifact ${currentArtifactIndex + 1} Description`}
</Text>
<TextInput
className="border rounded-[10px] p-2.5 h-[150px] text-base mb-4"
style={{
backgroundColor: theme.colors.input,
color: theme.colors.text,
borderColor: theme.colors.border,
textAlignVertical: 'top',
}}
placeholder={
isCharacter
? 'Describe your character...'
: `Describe artifact ${currentArtifactIndex + 1}...`
}
placeholderTextColor={isDark ? '#888' : '#aaa'}
multiline
numberOfLines={8}
value={currentDescription}
onChangeText={(text) => {
if (isCharacter) {
handleCharacterDescriptionChange(text);
} else {
handleArtifactDescriptionChange(currentArtifactIndex, text);
}
}}
/>
<View className="flex-row justify-between">
<TouchableOpacity
className="rounded-[10px] p-2.5 min-w-[100px] items-center"
style={{ backgroundColor: theme.colors.border }}
onPress={() => setDescriptionModalVisible(false)}
>
<Text style={{ color: theme.colors.text }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className="rounded-[10px] p-2.5 min-w-[100px] items-center"
style={{ backgroundColor: theme.colors.primary }}
onPress={() => {
setDescriptionModalVisible(false);
}}
>
<Text className="text-white">Save</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
// Render the form content directly without the outer container
const renderFormContent = () => {
return (
<View
className="border-0 rounded-[20px] p-5 pt-[30px] mb-[5px] w-full overflow-hidden"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.75)' }}
>
{/* Name Input and Image Upload */}
<View className="w-full flex-row items-center justify-between mb-5">
<TextInput
className="border rounded-[15px] p-4 h-[50px] text-2xl font-bold text-left flex-1 mr-2.5"
style={{
backgroundColor:
focusedInput === 'name' ? theme.colors.inputActive : theme.colors.input,
color: theme.colors.text,
borderColor: theme.colors.border,
}}
placeholder="Name"
placeholderTextColor={isDark ? '#888' : '#aaa'}
value={formData.name}
onFocus={() => setFocusedInput('name')}
onBlur={() => setFocusedInput(null)}
onChangeText={handleNameChange}
autoCapitalize="words"
autoCorrect={false}
/>
{/* Image Upload Button */}
<TouchableOpacity
className="w-[60px] h-[50px] border rounded-[15px] justify-center items-center"
style={{
backgroundColor: isDark ? '#1a1a1a' : '#f0f0f0',
borderColor: theme.colors.primary,
borderWidth: 2,
}}
onPress={() => pickImage('character')}
>
{formData.characterImage ? (
<Image
source={{ uri: formData.characterImage }}
className="w-full h-full rounded-[14px]"
resizeMode="cover"
/>
) : (
<>
<FontAwesome name="image" size={18} color={theme.colors.primary} />
<Text
style={{
color: theme.colors.primary,
fontSize: 12,
marginTop: 2,
fontWeight: 'bold',
}}
>
Image
</Text>
</>
)}
</TouchableOpacity>
</View>
<View className="w-full">
{/* Eingabefelder */}
<View className="w-full">
{/* Character Beschreibung */}
<View className="mb-2 w-full">
<View
className="border rounded-[15px] p-0 h-20 justify-center items-center relative overflow-hidden mb-4"
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.input }}
>
<TextInput
className="w-full h-full px-4 py-4 text-base"
style={{
backgroundColor:
focusedInput === 'character' ? theme.colors.inputActive : theme.colors.input,
color: theme.colors.text,
borderColor: theme.colors.border,
textAlignVertical: 'top',
}}
autoCapitalize="none"
autoCorrect={false}
placeholder="Describe your character..."
placeholderTextColor={isDark ? '#888' : '#aaa'}
multiline
numberOfLines={4}
value={formData.characterDescription}
onFocus={() => setFocusedInput('character')}
onBlur={() => setFocusedInput(null)}
onChangeText={handleCharacterDescriptionChange}
/>
</View>
{/* Character Bild Upload removed - now next to name */}
</View>
{/* Sichtbarkeit - nach oben verschoben - mit negativem Margin für engeren Abstand */}
<View className="mb-2 w-full -mt-[5px] mb-0">
<View
className="flex-row justify-between items-center p-2.5 rounded-[10px] my-0.5"
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.input }}
>
<Text style={{ color: theme.colors.text }}>Publish Figgo</Text>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: theme.colors.border, true: theme.colors.primary }}
thumbColor={isPublic ? theme.colors.card : '#f4f3f4'}
/>
</View>
</View>
{/* Divider über Artefakten */}
<View
className="h-[1px] w-auto my-4 -mx-[30px]"
style={{ backgroundColor: theme.colors.border }}
/>
{/* Artefakt Beschreibungen */}
{[0, 1, 2].map((index) => (
<View key={`artifact-field-${index}`} className="mb-2.5 w-full">
<View className="flex-row items-start w-full">
<View className="w-[50px] mr-[5px] items-center">
<View className="w-[35px] h-[35px] justify-center items-center">
<FontAwesome name="cube" size={30} color={theme.colors.text} />
</View>
</View>
<View
className="border rounded-[15px] p-0 h-20 justify-center items-center relative overflow-hidden mb-4 flex-1"
style={{
borderColor: theme.colors.border,
backgroundColor: theme.colors.input,
}}
>
<TextInput
className="w-full h-full px-4 py-4 text-base"
style={{
backgroundColor:
focusedInput === `artifact-${index}`
? theme.colors.inputActive
: theme.colors.input,
color: theme.colors.text,
borderColor: theme.colors.border,
textAlignVertical: 'top',
}}
autoCapitalize="none"
autoCorrect={false}
placeholder={`Describe Item ${index + 1}...`}
placeholderTextColor={isDark ? '#888' : '#aaa'}
multiline
numberOfLines={4}
value={formData.artifacts[index].description}
onFocus={() => setFocusedInput(`artifact-${index}` as any)}
onBlur={() => setFocusedInput(null)}
onChangeText={(text) => handleArtifactDescriptionChange(index, text)}
/>
</View>
</View>
</View>
))}
{/* Divider unter Artefakten */}
<View
className="h-[1px] w-auto my-4 -mx-[30px]"
style={{ backgroundColor: theme.colors.border }}
/>
</View>
</View>
</View>
);
};
// Expose the handleSubmit function to the parent component
// Verwende useEffect mit vollständiger Dependency-Liste, um sicherzustellen,
// dass der Handler aktualisiert wird, wenn sich formData oder isPublic ändert
React.useEffect(() => {
if (onSubmit) {
onSubmit(handleSubmit);
}
}, [onSubmit, handleSubmit, formData, isPublic]);
// Stelle sicher, dass der Handler sofort beim ersten Rendern verfügbar ist
React.useEffect(() => {
if (onSubmit) {
onSubmit(handleSubmit);
}
}, []);
return (
<View className="flex-1 w-full p-0 m-0">
{isWeb ? (
<View
className="rounded-[20px] overflow-hidden w-full p-1 my-5 self-stretch border-0"
style={{ backdropFilter: 'blur(25px)' }}
>
{renderFormContent()}
</View>
) : Platform.OS === 'ios' ? (
<BlurView
intensity={90}
className="rounded-[20px] overflow-hidden w-full p-1 my-5 self-stretch border-0"
>
{renderFormContent()}
</BlurView>
) : (
<View
className="rounded-[20px] overflow-hidden w-full p-1 my-5 self-stretch border-0"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)' }}
>
{renderFormContent()}
</View>
)}
{/* Description Modal */}
{renderDescriptionModal()}
</View>
);
};
export default SidebarCreateFigureForm;

View file

@ -0,0 +1,52 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useTheme } from '../utils/ThemeContext';
import FontAwesome from '@expo/vector-icons/FontAwesome';
interface ErrorMessageProps {
message: string;
visible: boolean;
}
const ErrorMessage: React.FC<ErrorMessageProps> = ({ message, visible }) => {
const { theme } = useTheme();
if (!visible) return null;
return (
<View
style={[
styles.container,
{ backgroundColor: theme.colors.error + '20', borderColor: theme.colors.error },
]}
>
<FontAwesome
name="exclamation-circle"
size={16}
color={theme.colors.error}
style={styles.icon}
/>
<Text style={[styles.message, { color: theme.colors.error }]}>{message}</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 8,
borderWidth: 1,
marginBottom: 15,
},
icon: {
marginRight: 8,
},
message: {
fontSize: 14,
flex: 1,
},
});
export default ErrorMessage;

View file

@ -0,0 +1,312 @@
import React, { useState, useRef, useCallback } from 'react';
import { Dimensions, ImageSourcePropType, TouchableOpacity, View } from 'react-native';
import Animated, {
FadeIn,
useAnimatedStyle,
useSharedValue,
withTiming,
Easing as ReanimatedEasing,
} from 'react-native-reanimated';
import { useTheme } from '~/utils/ThemeContext';
import { CardInfoPanel } from './FigureCardInfo';
import { FigureInfoModal } from './FigureInfoModal';
// Typ für den aktiven Tab im Modal
type ActiveTabType = 'character' | 'item1' | 'item2' | 'item3' | null;
interface VerticalFigureCardProps {
image: ImageSourcePropType;
title: string;
creator: string;
likes: number;
expanded?: boolean;
onToggleExpand?: () => void;
characterInfo?: {
character?: {
image_prompt?: string;
description?: string;
lore?: string;
};
items?: Array<{
name?: string;
image_prompt?: string;
description?: string;
lore?: string;
}>;
};
}
const { width, height } = Dimensions.get('window');
// Maximale Höhe für Desktop-Geräte (600px oder 80% der Bildschirmhöhe, je nachdem was kleiner ist)
const MAX_CARD_HEIGHT = Math.min(600, height * 0.8);
// Berechne die Breite basierend auf der maximalen Höhe und dem 2:3-Verhältnis
// Wenn die Höhe 3 Einheiten ist, dann ist die Breite 2 Einheiten
const CARD_WIDTH_BASED_ON_MAX_HEIGHT = (MAX_CARD_HEIGHT / 3) * 2;
// Verwende die kleinere der beiden Breiten, um sicherzustellen, dass das Bild auf dem Bildschirm passt
const CARD_WIDTH = Math.min(width, CARD_WIDTH_BASED_ON_MAX_HEIGHT);
// Berechne die Höhe basierend auf dem 2:3-Format und der gewählten Breite
const CARD_HEIGHT = (CARD_WIDTH / 2) * 3;
export const VerticalFigureCard: React.FC<VerticalFigureCardProps> = ({
image,
title,
creator,
likes: initialLikes,
expanded = false,
onToggleExpand = () => {},
characterInfo,
}) => {
const { theme, debugBorders, isDark } = useTheme();
// Debug description props
console.log('VerticalFigureCard props:', { title, characterInfo });
// State for tracking user interactions
const [liked, setLiked] = useState(false);
const [likes, setLikes] = useState(initialLikes);
const [imageLoaded, setImageLoaded] = useState(false);
const [showInfoModal, setShowInfoModal] = useState(false);
const [activeTab, setActiveTab] = useState<ActiveTabType>(null);
// Animation values
const imageOpacity = useSharedValue(0);
const imageMarginLeft = useSharedValue(0);
const imageMarginRight = useSharedValue(0);
// Handle like action
const handleLike = () => {
if (liked) {
setLikes(likes - 1);
} else {
setLikes(likes + 1);
}
setLiked(!liked);
};
// Handle other actions
const handleShare = () => {
console.log('Share', title);
};
// Toggle info modal mit Animationen
const toggleInfoModal = () => {
if (showInfoModal) {
setShowInfoModal(false);
setActiveTab(null);
// Zurücksetzen der Margins mit Animation
imageMarginLeft.value = withTiming(0, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
imageMarginRight.value = withTiming(0, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
} else {
setShowInfoModal(true);
setActiveTab('character'); // Standard-Tab beim Öffnen
// Initiale Animation für Character-Tab
imageMarginLeft.value = withTiming(-200, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
imageMarginRight.value = withTiming(0, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
}
};
// Handler für Tab-Wechsel im Modal mit Animationen
const handleTabChange = (tab: ActiveTabType) => {
setActiveTab(tab);
// Animiere die Margins basierend auf dem aktiven Tab
if (tab === 'character') {
// Animiere nach links für Character-Tab
imageMarginLeft.value = withTiming(-200, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
imageMarginRight.value = withTiming(0, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
} else if (tab?.startsWith('item')) {
// Animiere nach rechts für Item-Tabs
imageMarginLeft.value = withTiming(0, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
imageMarginRight.value = withTiming(-120, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
} else {
// Zurücksetzen, wenn kein Tab aktiv ist
imageMarginLeft.value = withTiming(0, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
imageMarginRight.value = withTiming(0, {
duration: 400,
easing: ReanimatedEasing.inOut(ReanimatedEasing.ease),
});
}
};
// Debug border styles
const debugCardStyle = debugBorders ? { borderWidth: 2, borderColor: '#FF00FF' } : {};
const debugContainerStyle = debugBorders ? { borderWidth: 1, borderColor: '#00FFFF' } : {};
const debugImageStyle = debugBorders ? { borderWidth: 1, borderColor: '#FF0000' } : {};
const debugInfoStyle = debugBorders ? { borderWidth: 1, borderColor: '#00FF00' } : {};
const debugImageContentStyle = debugBorders
? { borderWidth: 2, borderColor: '#FFFF00', borderStyle: 'dashed' }
: {};
// Handle image load completion
const onImageLoaded = useCallback(() => {
// Markiere das Bild als geladen
setImageLoaded(true);
// Animate opacity to 1 when image is fully loaded
// Längere Dauer für ein langsameres Einblenden mit Ease-In-Effekt
imageOpacity.value = withTiming(1, {
duration: 1200,
easing: ReanimatedEasing.in(ReanimatedEasing.ease), // Ease-In-Kurve für sanften Start
});
}, []);
// Create animated style for the image and container
const imageAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: imageOpacity.value,
};
});
// Animated style für die Margins des Containers
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
marginLeft: imageMarginLeft.value,
marginRight: imageMarginRight.value,
};
});
// Verwende die gleiche Höhe wie das Bild für die gesamte Karte
const cardHeight = CARD_HEIGHT;
return (
<View
className="w-full overflow-visible mb-[10px] flex flex-col items-center"
style={debugCardStyle}
>
{/* Card with image */}
<View
className="w-full overflow-visible flex items-center justify-center"
style={{ height: cardHeight }}
>
<View
className="w-full h-full relative flex items-center justify-center"
style={debugContainerStyle}
>
{/* Image container */}
<TouchableOpacity activeOpacity={0.9} onPress={toggleInfoModal} className="w-full h-full">
<Animated.View
style={[
{
width: CARD_WIDTH,
height: CARD_HEIGHT,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
alignSelf: 'center',
borderRadius: 12,
},
debugImageStyle,
containerAnimatedStyle,
]}
>
{/* Black loader card with low opacity */}
<View
style={{
position: 'absolute',
zIndex: 1,
width: '80%', // Deutlich reduziert für viel mehr Abstand links/rechts
height: '90%', // Etwas mehr Abstand oben/unten
borderRadius: 20,
backgroundColor: '#000000',
opacity: 0.2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 6,
elevation: 5,
}}
/>
{/* The actual image - only show when loaded */}
{imageLoaded && (
<Animated.Image
source={image}
style={[
{
width: '100%',
height: '100%',
borderRadius: 12,
zIndex: 2,
resizeMode: 'contain',
},
imageAnimatedStyle,
debugImageContentStyle,
]}
resizeMode="contain"
sharedTransitionTag={`figure-image-${title}`}
entering={FadeIn.duration(1200).easing(
ReanimatedEasing.in(ReanimatedEasing.ease)
)}
/>
)}
{/* Invisible image for loading - not displayed */}
<Animated.Image
source={image}
style={{ width: 1, height: 1, opacity: 0, position: 'absolute' }}
onLoad={onImageLoaded}
/>
</Animated.View>
</TouchableOpacity>
</View>
</View>
{/* Figure card info panel under the image */}
<CardInfoPanel
title={title}
creator={creator}
likes={likes}
isLiked={liked}
onLike={handleLike}
onShare={handleShare}
/>
{/* Using the new FigureInfoModal component */}
<FigureInfoModal
visible={showInfoModal}
onClose={toggleInfoModal}
title={title}
creator={creator}
characterInfo={characterInfo}
activeTab={activeTab || 'character'}
onTabChange={handleTabChange}
/>
</View>
);
};
// Holen Sie sich die Bildschirmbreite für die Bildgröße
const { width: SCREEN_WIDTH } = Dimensions.get('window');

View file

@ -0,0 +1,84 @@
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { useTheme } from '~/utils/ThemeContext';
import { ThemedText } from './ThemedView';
interface CardInfoPanelProps {
title: string;
creator: string;
likes: number;
isLiked: boolean;
onLike: () => void;
onShare: () => void;
}
export const CardInfoPanel: React.FC<CardInfoPanelProps> = ({
title,
creator,
likes,
isLiked,
onLike,
onShare,
}) => {
const { theme, isDark } = useTheme();
return (
<View className="w-full px-3 py-3 items-center">
{/* Title and creator - centered */}
<View className="w-full mb-3 items-center">
<ThemedText
className="text-[20px] font-bold text-center"
numberOfLines={1}
ellipsizeMode="tail"
>
{title}
</ThemedText>
<ThemedText
className="text-[14px] opacity-70 mt-1 text-center"
numberOfLines={1}
ellipsizeMode="tail"
>
by {creator}
</ThemedText>
</View>
{/* Action icons - all four in a row */}
<View className="flex-row items-center justify-center mt-2 w-full">
<TouchableOpacity className="flex-row items-center mx-3 py-[6px]" onPress={onLike}>
<FontAwesome
name="heart"
size={22}
color={isLiked ? theme.colors.primary : theme.colors.text}
style={{ opacity: isLiked ? 1 : 0.5 }}
/>
{likes > 0 && (
<ThemedText className="text-[14px] ml-[5px] opacity-70">{likes}</ThemedText>
)}
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center mx-3 py-[6px]">
<FontAwesome
name="thumbs-up"
size={22}
color={theme.colors.text}
style={{ opacity: 0.5 }}
/>
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center mx-3 py-[6px]">
<FontAwesome
name="refresh"
size={22}
color={theme.colors.text}
style={{ opacity: 0.5 }}
/>
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center mx-3 py-[6px]" onPress={onShare}>
<FontAwesome name="share" size={22} color={theme.colors.text} style={{ opacity: 0.5 }} />
</TouchableOpacity>
</View>
</View>
);
};

View file

@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { View, TouchableOpacity, Modal, ScrollView, Dimensions } from 'react-native';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { useTheme } from '~/utils/ThemeContext';
import { ThemedText, ThemedView } from './ThemedView';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
interface FigureInfoModalProps {
visible: boolean;
onClose: () => void;
title: string;
creator: string;
activeTab: 'character' | 'item1' | 'item2' | 'item3';
onTabChange: (tab: 'character' | 'item1' | 'item2' | 'item3') => void;
characterInfo?: {
character?: {
description?: string;
lore?: string;
};
items?: Array<{
name?: string;
description?: string;
lore?: string;
}>;
};
}
export const FigureInfoModal: React.FC<FigureInfoModalProps> = ({
visible,
onClose,
title,
creator,
activeTab,
onTabChange,
characterInfo,
}) => {
const { theme, isDark } = useTheme();
// Prüfen, ob Daten für die verschiedenen Tabs vorhanden sind
const hasCharacter = !!characterInfo?.character?.description;
const hasItems = !!characterInfo?.items && characterInfo.items.length > 0;
const itemCount = hasItems ? Math.min(characterInfo!.items!.length, 3) : 0;
// Rendere den Inhalt basierend auf dem aktiven Tab
const renderContent = () => {
if (activeTab === 'character' && hasCharacter) {
return (
<View className="p-[5px] mb-[10px]">
<ThemedText className="text-[18px] font-bold mb-[10px]">Character</ThemedText>
<ThemedText className="text-[15px] leading-[22px]">
{characterInfo?.character?.description || 'No description available.'}
</ThemedText>
{characterInfo?.character?.lore && (
<View className="mt-[15px]">
<ThemedText className="text-[14px] font-bold mb-[5px] italic">Lore</ThemedText>
<ThemedText className="text-[14px] italic leading-[20px]">
{characterInfo.character.lore}
</ThemedText>
</View>
)}
</View>
);
} else if (activeTab.startsWith('item') && hasItems) {
const itemIndex = parseInt(activeTab.replace('item', '')) - 1;
if (itemIndex >= 0 && itemIndex < itemCount) {
const item = characterInfo!.items![itemIndex];
return (
<View className="p-[5px] mb-[10px]">
<ThemedText className="text-[18px] font-bold mb-[10px]">
{item.name || `Item ${itemIndex + 1}`}
</ThemedText>
<ThemedText className="text-[15px] leading-[22px]">
{item.description || 'No description available.'}
</ThemedText>
{item.lore && (
<View className="mt-[15px]">
<ThemedText className="text-[14px] font-bold mb-[5px] italic">Lore</ThemedText>
<ThemedText className="text-[14px] italic leading-[20px]">{item.lore}</ThemedText>
</View>
)}
</View>
);
}
}
return (
<View className="p-[5px] mb-[10px]">
<ThemedText className="text-[15px] leading-[22px]">No information available.</ThemedText>
</View>
);
};
return (
<Modal visible={visible} transparent={true} animationType="fade" onRequestClose={onClose}>
<TouchableOpacity
className="flex-1 bg-transparent justify-start items-center pt-[150px]"
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
className={`w-[90%] max-w-[500px] h-[480px] rounded-2xl overflow-hidden relative shadow-md ${isDark ? 'bg-[#333333]' : 'bg-[#f0f0f0]'} ${activeTab === 'character' ? 'w-[70%] self-end' : activeTab.startsWith('item') ? 'w-[70%] self-start' : ''}`}
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header with title and creator */}
<View className="pt-[25px] pb-[15px] px-5 items-center border-b border-b-[rgba(150,150,150,0.3)]">
<ThemedText className="text-[22px] font-bold mb-[5px] text-center">{title}</ThemedText>
<ThemedText className="text-[14px] opacity-70 text-center">by {creator}</ThemedText>
</View>
{/* Content area */}
<ScrollView
className="w-full max-h-[70%]"
contentContainerClassName="p-5"
showsVerticalScrollIndicator={true}
>
{renderContent()}
</ScrollView>
{/* Tab bar at the bottom */}
<View
className={`flex-row justify-around items-center h-[60px] border-t border-t-[rgba(150,150,150,0.3)] ${isDark ? 'bg-[#222222]' : 'bg-[#e0e0e0]'}`}
>
{/* Character Tab */}
<TouchableOpacity
className={`flex-1 h-full justify-center items-center py-[10px] ${activeTab === 'character' ? 'border-b-[3px]' : ''}`}
style={activeTab === 'character' ? { borderBottomColor: theme.colors.primary } : {}}
onPress={() => onTabChange('character')}
disabled={!hasCharacter}
>
<FontAwesome
name="user"
size={20}
color={activeTab === 'character' ? theme.colors.primary : theme.colors.text}
style={{ opacity: hasCharacter ? 1 : 0.3 }}
/>
<ThemedText
className={`text-[12px] mt-[4px] ${!hasCharacter ? 'opacity-30' : ''}`}
style={
activeTab === 'character'
? { color: theme.colors.primary, fontWeight: 'bold' }
: {}
}
>
Character
</ThemedText>
</TouchableOpacity>
{/* Item Tabs */}
{[1, 2, 3].map((num) => (
<TouchableOpacity
key={`tab-item-${num}`}
className={`flex-1 h-full justify-center items-center py-[10px] ${activeTab === `item${num}` ? 'border-b-[3px]' : ''}`}
style={
activeTab === `item${num}` ? { borderBottomColor: theme.colors.primary } : {}
}
onPress={() =>
itemCount >= num && onTabChange(`item${num}` as 'item1' | 'item2' | 'item3')
}
disabled={itemCount < num}
>
<FontAwesome
name="cube"
size={20}
color={activeTab === `item${num}` ? theme.colors.primary : theme.colors.text}
style={{ opacity: itemCount >= num ? 1 : 0.3 }}
/>
<ThemedText
className={`text-[12px] mt-[4px] ${itemCount < num ? 'opacity-30' : ''}`}
style={
activeTab === `item${num}`
? { color: theme.colors.primary, fontWeight: 'bold' }
: {}
}
>
Item {num}
</ThemedText>
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
};

View file

@ -0,0 +1,144 @@
import React, { forwardRef } from 'react';
import { View, Pressable, Image, Animated, StyleSheet } from 'react-native';
import { router } from 'expo-router';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Ionicons } from '@expo/vector-icons';
import { ThemedText } from './ThemedView';
import { useTheme } from '~/utils/ThemeContext';
interface HeaderProps {
title?: string;
scrollY?: Animated.Value;
onPress?: () => void;
standalone?: boolean;
}
export const Header: React.FC<HeaderProps> = ({ title, scrollY, onPress, standalone = false }) => {
const { theme } = useTheme();
// Animation für die Header-Elemente
const headerOpacity = scrollY
? scrollY.interpolate({
inputRange: [0, 50],
outputRange: [1, 0],
extrapolate: 'clamp',
})
: new Animated.Value(1);
const headerTranslateY = scrollY
? scrollY.interpolate({
inputRange: [0, 50],
outputRange: [0, -50],
extrapolate: 'clamp',
})
: new Animated.Value(0);
// If standalone is true, return just the settings button (for use in tab headers)
if (standalone) {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="cog"
size={25}
color={theme.colors.text}
style={{
marginRight: 15,
opacity: pressed ? 0.5 : 1,
}}
/>
)}
</Pressable>
);
}
// Otherwise return the full header with title and app icon
return (
<Animated.View
style={[
styles.headerContainer,
{
opacity: headerOpacity,
transform: [{ translateY: headerTranslateY }],
},
]}
>
<View style={styles.leftSection}>
<Image source={require('../assets/icon.png')} style={styles.appIcon} resizeMode="contain" />
<ThemedText style={styles.title}>{title}</ThemedText>
</View>
<View style={styles.rightSection}>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.7 }]}
onPress={() => router.push('/subscription')}
>
{({ pressed }) => (
<Ionicons
name="diamond"
size={24}
color={theme.colors.primary}
style={{ opacity: pressed ? 0.7 : 1 }}
/>
)}
</Pressable>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.7 }]}
onPress={onPress || (() => router.push('/settings'))}
>
{({ pressed }) => (
<FontAwesome
name="gear"
size={24}
color={theme.colors.text}
style={{ opacity: pressed ? 0.3 : 0.5 }}
/>
)}
</Pressable>
</View>
</Animated.View>
);
};
// Definiere Styles außerhalb der Komponente für bessere Performance
const styles = StyleSheet.create({
headerContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 10,
width: '100%',
position: 'absolute',
top: 10,
zIndex: 100,
height: 52,
},
leftSection: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
rightSection: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 10,
},
appIcon: {
width: 24,
height: 24,
marginRight: 10,
},
title: {
fontSize: 18,
fontWeight: 'bold',
},
iconButton: {
width: 38,
height: 38,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 10,
},
});

View file

@ -0,0 +1,25 @@
import { Text, View } from 'react-native';
import { useTheme } from '~/utils/ThemeContext';
type ScreenContentProps = {
title: string;
path: string;
children?: React.ReactNode;
};
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
const { isDark, theme } = useTheme();
return (
<View className={`${styles.container} ${isDark ? 'bg-gray-900' : 'bg-white'}`}>
<Text className={`${styles.title} ${isDark ? 'text-white' : 'text-black'}`}>{title}</Text>
<View className={`${styles.separator} ${isDark ? 'bg-gray-700' : 'bg-gray-200'}`} />
{children}
</View>
);
};
const styles = {
container: `items-center flex-1 justify-center`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
};

View file

@ -0,0 +1,31 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Platform } from 'react-native';
import { useTheme } from '~/utils/ThemeContext';
// Mapping von Icon-Namen für verschiedene Plattformen
const iconMap: Record<string, { ios: string; android: string }> = {
community: { ios: 'people', android: 'people' },
create: { ios: 'add-circle', android: 'add-circle' },
shelf: { ios: 'grid', android: 'grid' },
};
export const TabBarIcon = (props: { name: string; color: string; focused?: boolean }) => {
const { name, color, focused } = props;
// Wähle das richtige Icon basierend auf der Plattform
const mappedName = iconMap[name] || { ios: 'help-circle', android: 'help-circle' };
const iconName = Platform.OS === 'ios' ? mappedName.ios : mappedName.android;
// Bestimme den vollständigen Icon-Namen (mit oder ohne Outline)
const fullIconName = (
focused ? iconName : `${iconName}-outline`
) as keyof typeof Ionicons.glyphMap;
return <Ionicons name={fullIconName} size={26} style={styles.tabBarIcon} color={color} />;
};
export const styles = StyleSheet.create({
tabBarIcon: {
marginBottom: -3,
},
});

View file

@ -0,0 +1,87 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle, TextStyle, ViewProps, TextProps } from 'react-native';
import { useTheme } from '~/utils/ThemeContext';
// Debug border colors for different components
const DEBUG_COLORS = {
primary: '#FF0000', // Red
secondary: '#00FF00', // Green
tertiary: '#0000FF', // Blue
quaternary: '#FF00FF', // Magenta
default: '#FFFF00', // Yellow
};
// Themed View component
interface ThemedViewProps extends ViewProps {
style?: ViewStyle | ViewStyle[];
darkStyle?: ViewStyle;
lightStyle?: ViewStyle;
debugBorderType?: keyof typeof DEBUG_COLORS;
}
export const ThemedView: React.FC<ThemedViewProps> = ({
style,
darkStyle,
lightStyle,
debugBorderType = 'default',
children,
...props
}) => {
const { isDark, theme, debugBorders } = useTheme();
const themeSpecificStyle = isDark ? darkStyle : lightStyle;
// Apply debug borders if enabled
const debugStyle: ViewStyle = debugBorders
? {
borderWidth: 1,
borderColor: DEBUG_COLORS[debugBorderType],
}
: {};
// Bestimme, ob ein expliziter Hintergrund in den Styles gesetzt wurde
const hasExplicitBackground =
(style && typeof style === 'object' && !Array.isArray(style) && 'backgroundColor' in style) ||
(Array.isArray(style) &&
style.some((s) => s && typeof s === 'object' && 'backgroundColor' in s));
return (
<View
style={[
// Setze den Theme-Hintergrund nur, wenn kein expliziter Hintergrund definiert wurde
!hasExplicitBackground ? { backgroundColor: theme.colors.background } : {},
style,
themeSpecificStyle,
debugStyle,
]}
{...props}
>
{children}
</View>
);
};
// Themed Text component
interface ThemedTextProps extends TextProps {
style?: TextStyle | TextStyle[];
darkStyle?: TextStyle;
lightStyle?: TextStyle;
}
export const ThemedText: React.FC<ThemedTextProps> = ({
style,
darkStyle,
lightStyle,
children,
...props
}) => {
const { isDark, theme } = useTheme();
const themeSpecificStyle = isDark ? darkStyle : lightStyle;
return (
<Text style={[{ color: theme.colors.text }, style, themeSpecificStyle]} {...props}>
{children}
</Text>
);
};

View file

@ -0,0 +1,59 @@
import React from 'react';
import { View, Text, Pressable } from 'react-native';
import { useTheme } from '../../utils/ThemeContext';
import SubscriptionButton from './SubscriptionButton';
export type BillingCycle = 'monthly' | 'yearly';
interface BillingToggleProps {
billingCycle: BillingCycle;
onChange: (cycle: BillingCycle) => void;
yearlyDiscount?: string;
}
export const BillingToggle: React.FC<BillingToggleProps> = ({
billingCycle,
onChange,
yearlyDiscount = '50%',
}) => {
const { theme } = useTheme();
return (
<View className="flex-row bg-[rgba(255,255,255,0.05)] rounded-lg mb-6 p-1 md:max-w-md md:mx-auto">
<Pressable
className={`flex-1 py-3 items-center rounded-md flex-row justify-center ${billingCycle === 'monthly' ? 'bg-[rgba(255,255,255,0.1)]' : ''}`}
onPress={() => onChange('monthly')}
>
<Text
className={`text-white text-sm md:text-base font-medium ${billingCycle === 'monthly' ? 'font-semibold' : ''}`}
style={billingCycle === 'monthly' ? { color: theme.colors.primary } : {}}
>
Monatlich
</Text>
</Pressable>
<Pressable
className={`flex-1 py-3 items-center rounded-md flex-row justify-center ${billingCycle === 'yearly' ? 'bg-[rgba(255,255,255,0.1)]' : ''}`}
onPress={() => onChange('yearly')}
>
<Text
className={`text-white text-sm md:text-base font-medium ${billingCycle === 'yearly' ? 'font-semibold' : ''}`}
style={billingCycle === 'yearly' ? { color: theme.colors.primary } : {}}
>
Jährlich
</Text>
{yearlyDiscount && (
<View
className="bg-primary rounded-xl px-2 py-0.5 ml-2"
style={{ backgroundColor: theme.colors.primary }}
>
<Text className="text-black text-[10px] md:text-xs font-bold">
{yearlyDiscount} Rabatt
</Text>
</View>
)}
</Pressable>
</View>
);
};
export default BillingToggle;

View file

@ -0,0 +1,39 @@
import React from 'react';
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../utils/ThemeContext';
export interface CostItem {
action: string;
cost: number;
icon: keyof typeof Ionicons.glyphMap;
}
interface CostCardProps {
title: string;
costs: CostItem[];
}
export const CostCard: React.FC<CostCardProps> = ({ title, costs }) => {
const { theme } = useTheme();
return (
<View className="bg-[rgba(255,255,255,0.05)] rounded-xl p-4 border border-[rgba(255,255,255,0.1)] h-full">
<Text className="text-white text-xl font-bold mb-4">{title}</Text>
<View className="space-y-3">
{costs.map((item, index) => (
<View key={index} className="flex-row justify-between items-center">
<View className="flex-row items-center">
<Ionicons name={item.icon} size={18} color={theme.colors.primary} />
<Text className="text-[rgba(255,255,255,0.8)] text-sm ml-2">{item.action}</Text>
</View>
<Text className="text-white text-base font-semibold">{item.cost} Mana</Text>
</View>
))}
</View>
</View>
);
};
export default CostCard;

View file

@ -0,0 +1,61 @@
import React from 'react';
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../utils/ThemeContext';
import SubscriptionButton from './SubscriptionButton';
export interface PackageProps {
id: string;
name: string;
manaAmount: number;
price: number;
isTeamPackage?: boolean;
popular?: boolean;
}
interface PackageCardProps {
package: PackageProps;
onSelect: (packageId: string) => void;
}
export const PackageCard: React.FC<PackageCardProps> = ({ package: pkg, onSelect }) => {
const { theme } = useTheme();
const formatPrice = (price: number) => {
return `${price.toFixed(2).replace('.', ',')}`;
};
return (
<View
className={`bg-[rgba(255,255,255,${pkg.isTeamPackage ? '0.08' : '0.05'})] rounded-xl p-4 border ${pkg.isTeamPackage ? 'border-primary' : 'border-[rgba(255,255,255,0.1)]'}`}
style={pkg.isTeamPackage ? { borderColor: theme.colors.primary } : {}}
>
{pkg.isTeamPackage && (
<View
className="absolute top-[-10px] right-4 rounded-xl px-2.5 py-1"
style={{ backgroundColor: theme.colors.primary }}
>
<Text className="text-black text-xs font-bold">Team</Text>
</View>
)}
<Text className="text-white text-xl font-bold mb-2">{pkg.name}</Text>
<Text className="text-white text-2xl font-bold mb-1">{pkg.manaAmount} Mana</Text>
<Text className="text-white text-xl font-bold">{formatPrice(pkg.price)}</Text>
<Text className="text-[rgba(255,255,255,0.7)] text-sm mt-1 mb-4">
({formatPrice(pkg.price / pkg.manaAmount)} pro Mana)
</Text>
<View className="mt-4">
<SubscriptionButton
label="Auswählen"
onPress={() => onSelect(pkg.id)}
iconName="chevron-forward"
variant={pkg.popular ? 'accent' : 'primary'}
/>
</View>
</View>
);
};
export default PackageCard;

View file

@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { Pressable, Text, View, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../utils/ThemeContext';
interface SubscriptionButtonProps {
label: string;
onPress: () => void;
iconName?: keyof typeof Ionicons.glyphMap;
variant?: 'primary' | 'secondary' | 'accent';
disabled?: boolean;
}
export const SubscriptionButton: React.FC<SubscriptionButtonProps> = ({
label,
onPress,
iconName = 'chevron-forward',
variant = 'primary',
disabled = false,
}) => {
const { theme } = useTheme();
const [isHovered, setIsHovered] = useState(false);
// Bestimme die Klassen basierend auf der Variante und dem Hover-Status
const getButtonClasses = () => {
const baseClasses = 'flex-row items-center justify-between py-2.5 px-4 rounded-lg';
const disabledClass = disabled ? 'opacity-50' : '';
const hoverClass = isHovered && !disabled ? 'opacity-90' : '';
switch (variant) {
case 'accent':
return `${baseClasses} ${hoverClass} ${disabledClass}`;
case 'primary':
return `${baseClasses} bg-[rgba(255,255,255,0.08)] border border-[rgba(255,255,255,0.15)] ${hoverClass} ${disabledClass}`;
case 'secondary':
default:
return `${baseClasses} bg-[rgba(255,255,255,0.03)] border border-[rgba(255,255,255,0.1)] ${hoverClass} ${disabledClass}`;
}
};
// Bestimme die Textklassen basierend auf der Variante
const getTextClasses = () => {
const baseClasses = 'text-sm font-medium';
switch (variant) {
case 'accent':
return `${baseClasses} text-black font-semibold`;
case 'primary':
return `${baseClasses} text-white`;
case 'secondary':
default:
return `${baseClasses} text-[rgba(255,255,255,0.8)]`;
}
};
return (
<Pressable
className={getButtonClasses()}
onPress={disabled ? undefined : onPress}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [
variant === 'accent' ? { backgroundColor: theme.colors.primary } : {},
pressed && !disabled ? { opacity: 0.75, transform: [{ scale: 0.98 }] } : {},
isHovered && !disabled && !pressed
? {
opacity: 0.9,
transform: [{ scale: 1.02 }],
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 3,
elevation: 2,
}
: {},
]}
>
<View className="flex-row items-center">
<Text className={getTextClasses()}>{label}</Text>
</View>
<Ionicons
name={iconName}
size={16}
color={
variant === 'accent'
? '#000000'
: variant === 'primary'
? '#FFFFFF'
: 'rgba(255,255,255,0.8)'
}
style={{ marginLeft: 8 }}
/>
</Pressable>
);
};
export default SubscriptionButton;

View file

@ -0,0 +1,125 @@
import React from 'react';
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../utils/ThemeContext';
import SubscriptionButton from './SubscriptionButton';
export interface SubscriptionPlanProps {
id: string;
name: string;
price: number;
priceUnit: string;
priceBreakdown?: string;
initialMana: number;
dailyMana: number;
maxMana: number;
canGiftMana: boolean;
popular?: boolean;
billingCycle?: 'monthly' | 'yearly';
monthlyEquivalent?: number;
}
interface SubscriptionCardProps {
plan: SubscriptionPlanProps;
onSelect: (planId: string) => void;
}
export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ plan, onSelect }) => {
const { theme } = useTheme();
const formatPrice = (price: number) => {
return `${price.toFixed(2).replace('.', ',')}`;
};
return (
<View
className={`bg-[rgba(255,255,255,0.05)] rounded-xl p-4 border ${plan.popular ? 'border-primary' : 'border-[rgba(255,255,255,0.1)]'}`}
style={plan.popular ? { borderColor: theme.colors.primary } : {}}
>
{plan.popular && (
<View
className="absolute top-[-10px] right-4 rounded-xl px-2.5 py-1"
style={{ backgroundColor: theme.colors.primary }}
>
<Text className="text-black text-xs font-bold">Beliebt</Text>
</View>
)}
{/* Titel und Preis nebeneinander */}
<View className="flex-row justify-between items-center mb-4">
<Text className="text-white text-xl font-bold">{plan.name}</Text>
<Text className="text-white text-xl font-bold">
{formatPrice(plan.price)}
<Text className="text-sm"> {plan.priceUnit}</Text>
</Text>
</View>
{plan.priceBreakdown && (
<Text className="text-[rgba(255,255,255,0.7)] text-xs text-right mb-4">
{plan.priceBreakdown}
</Text>
)}
{/* Beschreibungen in einer Komponente */}
<View className="bg-[rgba(255,255,255,0.03)] rounded-lg p-4 mb-5">
{/* Titel mit Icons */}
<View className="flex-row items-center justify-between mb-4">
<View className="flex-row items-center">
<Ionicons name="gift-outline" size={18} color={theme.colors.primary} className="mr-2" />
<Text className="text-[rgba(255,255,255,0.7)] text-sm">Geschenk</Text>
</View>
<View className="flex-row items-center">
<Ionicons
name="refresh-outline"
size={18}
color={theme.colors.primary}
className="mr-2"
/>
<Text className="text-[rgba(255,255,255,0.7)] text-sm">Regeneration</Text>
</View>
<View className="flex-row items-center">
<Ionicons name="save-outline" size={18} color={theme.colors.primary} className="mr-2" />
<Text className="text-[rgba(255,255,255,0.7)] text-sm">Speicher</Text>
</View>
</View>
{/* Tabellenwerte mit größeren Zahlen */}
<View className="flex-row justify-between">
<View className="flex-1 items-center">
<Text className="text-white text-xl font-semibold">{plan.initialMana}</Text>
</View>
<View className="flex-1 items-center">
<Text className="text-white text-xl font-semibold">{plan.dailyMana}/Tag</Text>
</View>
<View className="flex-1 items-center">
<Text className="text-white text-xl font-semibold">Max. {plan.maxMana}</Text>
</View>
</View>
</View>
{/* Mana verschenken Badge für bezahlte Pläne */}
{plan.id !== 'free' && plan.canGiftMana && (
<View className="flex-row items-center justify-center mb-4">
<Ionicons name="gift" size={16} color={theme.colors.primary} className="mr-1" />
<Text className="text-[rgba(255,255,255,0.7)] text-sm">Mana verschenken möglich</Text>
</View>
)}
<SubscriptionButton
label="Auswählen"
onPress={() => onSelect(plan.id)}
iconName="chevron-forward"
variant={plan.popular ? 'accent' : 'primary'}
/>
{/* Mana verschenken Info unter dem Button für Free-Tier */}
{plan.id === 'free' && (
<Text className="text-[rgba(255,255,255,0.5)] text-xs text-center mt-3">
Im kostenlosen Plan ist das Verschenken von Mana nicht möglich.
</Text>
)}
</View>
);
};
export default SubscriptionCard;

View file

@ -0,0 +1,254 @@
import React, { useState } from 'react';
import { View, Text, ScrollView, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { useTheme } from '../../utils/ThemeContext';
import SubscriptionCard, { SubscriptionPlanProps } from './SubscriptionCard';
import PackageCard, { PackageProps } from './PackageCard';
import CostCard, { CostItem } from './CostCard';
import UsageCard, { UsageDataProps } from './UsageCard';
import BillingToggle from './BillingToggle';
import type { BillingCycle } from './BillingToggle';
// Importieren der Daten aus den JSON-Dateien
import subscriptionData from './subscriptionData.json';
import appCostsData from './appCosts.json';
import usageData from './usageData.json';
// Verwenden der Daten aus den JSON-Dateien
const subscriptionOptions: SubscriptionPlanProps[] =
subscriptionData.subscriptions as SubscriptionPlanProps[];
const manaPackages: PackageProps[] = subscriptionData.packages as PackageProps[];
// Kosten für verschiedene Aktionen in der App aus der JSON-Datei laden
const appCosts: CostItem[] = appCostsData.costs as CostItem[];
// Nutzungsdaten aus der JSON-Datei laden
const usage: UsageDataProps = usageData.usage as UsageDataProps;
interface SubscriptionPageProps {
onSubscribe?: (planId: string, billingCycle: BillingCycle) => void;
onBuyPackage?: (packageId: string) => void;
}
const SubscriptionPage: React.FC<SubscriptionPageProps> = ({ onSubscribe, onBuyPackage }) => {
const router = useRouter();
const { theme } = useTheme();
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
// In einer echten App würde dieser Wert aus dem Benutzerprofil oder der API kommen
const [activeSubscription, setActiveSubscription] = useState<SubscriptionPlanProps>(
subscriptionOptions[0]
);
const handleSubscribe = (planId: string) => {
if (onSubscribe) {
onSubscribe(planId, billingCycle);
} else {
// Fallback-Verhalten
console.log(`Subscribing to plan: ${planId}, billing cycle: ${billingCycle}`);
Alert.alert(
'Erfolgreich abonniert!',
`Du hast erfolgreich das ${subscriptionOptions.find((p) => p.id === planId)?.name} Abo abgeschlossen.`,
[{ text: 'OK', onPress: () => router.back() }]
);
}
};
const handleBuyPackage = (packageId: string) => {
if (onBuyPackage) {
onBuyPackage(packageId);
} else {
// Fallback-Verhalten
console.log(`Buying package: ${packageId}`);
const selectedPkg = manaPackages.find((p) => p.id === packageId);
Alert.alert(
'Erfolgreich gekauft!',
`Du hast erfolgreich das ${selectedPkg?.name} Paket mit ${selectedPkg?.manaAmount} Mana gekauft.`,
[{ text: 'OK', onPress: () => router.back() }]
);
}
};
// Aktualisiere die Abonnement-Optionen basierend auf dem Abrechnungszyklus
const getSubscriptionPlans = () => {
// Filtere das kostenlose Abonnement heraus, da es bereits im Aktiv-Bereich angezeigt wird
// Filtere auch nach dem ausgewählten Abrechnungszyklus
const paidPlans = subscriptionOptions.filter(
(plan) => plan.id !== 'free' && plan.billingCycle === billingCycle
);
return paidPlans.map((plan) => {
// Füge die monatliche Preisberechnung für jährliche Abos hinzu
if (billingCycle === 'yearly' && plan.monthlyEquivalent) {
return {
...plan,
priceBreakdown: `(entspricht ${plan.monthlyEquivalent.toFixed(2).replace('.', ',')}€ pro Monat)`,
};
}
return plan;
});
};
const renderSubscriptionOptions = () => {
const plans = getSubscriptionPlans();
return (
<View className="w-full">
<BillingToggle
billingCycle={billingCycle}
onChange={setBillingCycle}
yearlyDiscount="50%"
/>
<View className="flex-col md:flex-row md:flex-wrap gap-6 mb-6">
{plans.map((plan) => (
<View
key={plan.id}
className="w-full md:w-[calc(50%-12px)] lg:w-[calc(50%-12px)] xl:w-[calc(33.33%-16px)]"
>
<SubscriptionCard plan={plan} onSelect={handleSubscribe} />
</View>
))}
</View>
</View>
);
};
const renderPackageOptions = () => {
// Pakete in reguläre und Team-Pakete aufteilen
const regularPackages = manaPackages.filter((pkg) => !pkg.isTeamPackage);
const teamPackages = manaPackages.filter((pkg) => pkg.isTeamPackage);
return (
<View className="w-full">
<Text className="text-white text-2xl font-bold mb-2">Mana-Pakete</Text>
<Text className="text-[rgba(255,255,255,0.7)] text-base mb-6">
Kaufe einmalig Mana, um mehr Figuren zu generieren.
</Text>
<View className="flex-col md:flex-row md:flex-wrap gap-6 mb-8">
{regularPackages.map((pkg) => (
<View key={pkg.id} className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.33%-16px)]">
<PackageCard package={pkg} onSelect={handleBuyPackage} />
</View>
))}
</View>
{teamPackages.length > 0 && (
<>
<Text className="text-white text-xl font-bold mb-2 mt-8">Team-Pakete</Text>
<Text className="text-[rgba(255,255,255,0.7)] text-base mb-6">
Spezielle Pakete für Teams mit mehr Mana und zusätzlichen Funktionen.
</Text>
<View className="flex-col md:flex-row md:flex-wrap gap-6 mb-6">
{teamPackages.map((pkg) => (
<View
key={pkg.id}
className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.33%-16px)]"
>
<PackageCard package={pkg} onSelect={handleBuyPackage} />
</View>
))}
</View>
</>
)}
</View>
);
};
// Filtert beliebte Abonnements und Pakete
const getPopularItems = () => {
const popularSubscriptions = subscriptionOptions.filter((sub) => sub.popular);
const popularPackages = manaPackages.filter((pkg) => pkg.popular);
return { popularSubscriptions, popularPackages };
};
// Render des "Beliebt"-Abschnitts
const renderPopularSection = () => {
const { popularSubscriptions, popularPackages } = getPopularItems();
// Wenn keine beliebten Items vorhanden sind, zeige den Abschnitt nicht an
if (popularSubscriptions.length === 0 && popularPackages.length === 0) {
return null;
}
return (
<View className="mb-10">
<Text className="text-white text-2xl md:text-3xl font-bold mb-4 md:mb-6">Beliebt</Text>
<View className="flex-col md:flex-row md:flex-wrap gap-6">
{/* Beliebte Abonnements */}
{popularSubscriptions.map((plan) => (
<View key={plan.id} className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.33%-16px)]">
<SubscriptionCard plan={{ ...plan, popular: true }} onSelect={handleSubscribe} />
</View>
))}
{/* Beliebte Pakete */}
{popularPackages.map((pkg) => (
<View key={pkg.id} className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.33%-16px)]">
<PackageCard package={{ ...pkg, popular: true }} onSelect={handleBuyPackage} />
</View>
))}
</View>
</View>
);
};
// Render des aktiven Abonnements und der Kosten
const renderActiveSection = () => {
return (
<View className="mb-10">
<Text className="text-white text-2xl md:text-3xl font-bold mb-4 md:mb-6">Aktiv</Text>
<View className="flex-col md:flex-row lg:flex-row gap-6">
{/* Aktives Abonnement */}
<View className="w-full md:w-1/3 lg:w-1/3">
<SubscriptionCard plan={activeSubscription} onSelect={() => {}} />
</View>
{/* Nutzungs-Karte */}
<View className="w-full md:w-1/3 lg:w-1/3 mt-6 md:mt-0">
<UsageCard title="Mana-Nutzung" usageData={usage} />
</View>
{/* Kosten-Karte */}
<View className="w-full md:w-1/3 lg:w-1/3 mt-6 md:mt-0">
<CostCard title="Kosten in der App" costs={appCosts} />
</View>
</View>
</View>
);
};
return (
<ScrollView
className="flex-1"
contentContainerClassName="p-4 pb-8 md:px-8 lg:px-12 xl:px-16 max-w-screen-2xl mx-auto"
>
{/* Aktiver Abschnitt */}
{renderActiveSection()}
{/* Beliebter Abschnitt */}
<View className="pt-6 border-t border-t-[rgba(255,255,255,0.1)]">
{renderPopularSection()}
</View>
{/* Abonnements-Abschnitt */}
<View className="mb-10 pt-6 border-t border-t-[rgba(255,255,255,0.1)]">
<Text className="text-white text-2xl md:text-3xl font-bold mb-4 md:mb-6">Abonnements</Text>
{renderSubscriptionOptions()}
</View>
{/* Pakete-Abschnitt */}
<View className="mt-6 pt-6 border-t border-t-[rgba(255,255,255,0.1)]">
<Text className="text-white text-2xl md:text-3xl font-bold mb-4 md:mb-6">Pakete</Text>
{renderPackageOptions()}
</View>
</ScrollView>
);
};
export default SubscriptionPage;

View file

@ -0,0 +1,85 @@
import React from 'react';
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../utils/ThemeContext';
export interface UsageDataProps {
total: number;
lastWeek: number;
lastMonth: number;
currentMana: number;
maxMana: number;
history?: Array<{
date: string;
amount: number;
}>;
}
interface UsageCardProps {
title: string;
usageData: UsageDataProps;
}
export const UsageCard: React.FC<UsageCardProps> = ({ title, usageData }) => {
const { theme } = useTheme();
// Berechnung des Prozentsatzes für den Fortschrittsbalken
const manaPercentage = Math.min(
100,
Math.round((usageData.currentMana / usageData.maxMana) * 100)
);
return (
<View className="bg-[rgba(255,255,255,0.05)] rounded-xl p-4 border border-[rgba(255,255,255,0.1)]">
<Text className="text-white text-xl font-bold mb-4">{title}</Text>
{/* Mana-Fortschrittsbalken */}
<View className="mb-6">
<View className="flex-row justify-between mb-1">
<Text className="text-[rgba(255,255,255,0.7)] text-sm">Aktuelles Mana</Text>
<Text className="text-white text-sm font-bold">
{usageData.currentMana} / {usageData.maxMana}
</Text>
</View>
<View className="h-2.5 bg-[rgba(255,255,255,0.1)] rounded-full overflow-hidden">
<View
className="h-full rounded-full"
style={{
width: `${manaPercentage}%`,
backgroundColor: theme.colors.primary,
}}
/>
</View>
</View>
{/* Nutzungsstatistiken */}
<View className="space-y-4">
<View className="flex-row justify-between items-center">
<View className="flex-row items-center">
<Ionicons name="time-outline" size={18} color="rgba(255,255,255,0.7)" />
<Text className="text-[rgba(255,255,255,0.7)] text-sm ml-2">Letzte Woche</Text>
</View>
<Text className="text-white text-base font-bold">{usageData.lastWeek} Mana</Text>
</View>
<View className="flex-row justify-between items-center">
<View className="flex-row items-center">
<Ionicons name="calendar-outline" size={18} color="rgba(255,255,255,0.7)" />
<Text className="text-[rgba(255,255,255,0.7)] text-sm ml-2">Letzter Monat</Text>
</View>
<Text className="text-white text-base font-bold">{usageData.lastMonth} Mana</Text>
</View>
<View className="flex-row justify-between items-center">
<View className="flex-row items-center">
<Ionicons name="analytics-outline" size={18} color="rgba(255,255,255,0.7)" />
<Text className="text-[rgba(255,255,255,0.7)] text-sm ml-2">Insgesamt</Text>
</View>
<Text className="text-white text-base font-bold">{usageData.total} Mana</Text>
</View>
</View>
</View>
);
};
export default UsageCard;

View file

@ -0,0 +1,19 @@
{
"costs": [
{
"action": "Figur erstellen",
"cost": 100,
"icon": "create-outline"
},
{
"action": "Figuren kombinieren",
"cost": 100,
"icon": "git-merge-outline"
},
{
"action": "Figuren erraten",
"cost": 10,
"icon": "help-circle-outline"
}
]
}

View file

@ -0,0 +1,138 @@
{
"subscriptions": [
{
"id": "free",
"name": "Kostenlos",
"price": 0,
"priceUnit": "",
"initialMana": 200,
"dailyMana": 10,
"maxMana": 300,
"canGiftMana": false,
"popular": false,
"billingCycle": "monthly"
},
{
"id": "Mini",
"name": "Basic",
"price": 5.99,
"priceUnit": "/ Monat",
"initialMana": 800,
"dailyMana": 20,
"maxMana": 1000,
"canGiftMana": true,
"popular": true,
"billingCycle": "monthly"
},
{
"id": "Plus",
"name": "Premium",
"price": 14.99,
"priceUnit": "/ Monat",
"initialMana": 2500,
"dailyMana": 50,
"maxMana": 4500,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly"
},
{
"id": "Pro",
"name": "Unlimited",
"price": 29.99,
"priceUnit": "/ Monat",
"initialMana": 5000,
"dailyMana": 100,
"maxMana": 10000,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly"
},
{
"id": "Mini-yearly",
"name": "Basic",
"price": 59.99,
"priceUnit": "/ Jahr",
"initialMana": 1000,
"dailyMana": 25,
"maxMana": 1200,
"canGiftMana": true,
"popular": true,
"billingCycle": "yearly",
"monthlyEquivalent": 5.0
},
{
"id": "Plus-yearly",
"name": "Premium",
"price": 149.99,
"priceUnit": "/ Jahr",
"initialMana": 3000,
"dailyMana": 60,
"maxMana": 5000,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 12.5
},
{
"id": "Pro-yearly",
"name": "Unlimited",
"price": 299.99,
"priceUnit": "/ Jahr",
"initialMana": 6000,
"dailyMana": 120,
"maxMana": 12000,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 25.0
}
],
"packages": [
{
"id": "small",
"name": "100 Mana",
"manaAmount": 100,
"price": 4.99,
"popular": false
},
{
"id": "medium",
"name": "500 Mana",
"manaAmount": 500,
"price": 19.99,
"popular": true
},
{
"id": "large",
"name": "1000 Mana",
"manaAmount": 1000,
"price": 29.99,
"popular": false
},
{
"id": "team-small",
"name": "2000 Mana",
"manaAmount": 2000,
"price": 49.99,
"isTeamPackage": true,
"popular": false
},
{
"id": "team-medium",
"name": "5000 Mana",
"manaAmount": 5000,
"price": 79.99,
"isTeamPackage": true,
"popular": false
},
{
"id": "team-large",
"name": "10000 Mana",
"manaAmount": 10000,
"price": 99.99,
"isTeamPackage": true,
"popular": false
}
]
}

View file

@ -0,0 +1,18 @@
{
"usage": {
"total": 12450,
"lastWeek": 350,
"lastMonth": 1200,
"currentMana": 785,
"maxMana": 1000,
"history": [
{ "date": "2025-04-25", "amount": 50 },
{ "date": "2025-04-26", "amount": 70 },
{ "date": "2025-04-27", "amount": 30 },
{ "date": "2025-04-28", "amount": 45 },
{ "date": "2025-04-29", "amount": 80 },
{ "date": "2025-04-30", "amount": 55 },
{ "date": "2025-05-01", "amount": 20 }
]
}
}

21
games/figgos/eas.json Normal file
View file

@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 16.3.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

23
games/figgos/global.css Normal file
View file

@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Remove focus outlines globally */
* {
outline: none !important;
}
input, textarea, button, select, a {
-webkit-tap-highlight-color: transparent;
}
/* Specific React Native Web fixes */
input, textarea {
outline-width: 0;
outline-color: transparent;
outline-style: none;
box-shadow: none;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}

View file

@ -0,0 +1,9 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
/** @type {import('expo/metro-config').MetroConfig} */
// eslint-disable-next-line no-undef
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

3
games/figgos/nativewind-env.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

63
games/figgos/package.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": "@figgos/game",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start --dev-client",
"start": "expo start --dev-client",
"ios": "expo run:ios",
"android": "expo run:android",
"build:dev": "eas build --profile development",
"build:preview": "eas build --profile preview",
"build:prod": "eas build --profile production",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.49.4",
"expo": "^52.0.46",
"expo-blur": "~14.0.3",
"expo-constants": "~17.0.8",
"expo-dev-client": "~5.0.4",
"expo-dev-launcher": "^5.0.17",
"expo-image-picker": "^16.0.6",
"expo-linking": "~7.0.5",
"expo-router": "~4.0.6",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.9",
"expo-web-browser": "~14.0.2",
"nativewind": "latest",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.2",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.11.2",
"react-native-web": "~0.19.10"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~18.3.12",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"eslint": "^8.57.0",
"eslint-config-universe": "^12.0.1",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.3.3"
},
"eslintConfig": {
"extends": "universe/native",
"root": true
},
"private": true
}

View file

@ -0,0 +1,10 @@
module.exports = {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
plugins: [require.resolve('prettier-plugin-tailwindcss')],
tailwindAttributes: ['className'],
};

View file

@ -0,0 +1 @@
v2.22.6

View file

@ -0,0 +1,6 @@
// CORS-Header für die Edge Functions
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};

View file

@ -0,0 +1,412 @@
import OpenAI from 'https://deno.land/x/openai@v4.69.0/mod.ts';
import { createClient } from 'jsr:@supabase/supabase-js@2';
import { corsHeaders } from '../_shared/cors.ts';
/**
* Fetches a prompt template from Supabase
*/
async function fetchPromptTemplate(supabase, promptName) {
try {
const { data, error } = await supabase
.from('prompts')
.select('template')
.eq('name', promptName)
.single();
if (error) {
console.error(`Error fetching prompt '${promptName}':`, error);
return { template: null, error };
}
return { template: data.template, error: null };
} catch (error) {
console.error(`Exception fetching prompt '${promptName}':`, error);
return { template: null, error };
}
}
Deno.serve(async (req) => {
// Handle CORS preflight request
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Get environment variables
const apiKey = Deno.env.get('OPENAI_API_KEY');
const supabaseUrl = Deno.env.get('SUPABASE_URL');
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
if (!apiKey || !supabaseUrl || !supabaseKey) {
return new Response('Missing environment variables', {
status: 500,
headers: { ...corsHeaders },
});
}
// Initialize clients
const supabase = createClient(supabaseUrl, supabaseKey);
const openai = new OpenAI({
apiKey,
});
const body = await req.json();
// Validate required parameters
const subject = body.subject;
if (!subject) {
return new Response('Missing required field: subject', {
status: 400,
headers: { ...corsHeaders },
});
}
// Always generate descriptions first as a separate step
console.log('Generating character descriptions with LLM...');
let generatedDescriptions;
try {
const descriptionResponse = await openai.chat.completions.create({
model: 'gpt-4-turbo',
messages: [
{
role: 'system',
content: `You are an expert in creating detailed descriptions for action figures.
Based on the subject name, generate appropriate descriptions for the character and items (accessories).
For the character, provide a detailed description, a short summary, and a lore background.
For the items, be very specific and creative. Each item should be unique and thematically appropriate
for the character. Think of items that would enhance the character's abilities, reflect their personality, or
complement their story. Items should be visually interesting and varied in size and function.
For each item, provide:
1. A unique and fitting name
2. An image prompt (detailed visual description for image generation)
3. A short description (about 15 words)
4. A lore background (longer text explaining the item's history and significance)
Return your response as a JSON object with the following structure:
{
"character": {
"image_prompt": "Detailed visual description of the character with clothing, colors, materials, and style",
"description": "A concise description of the character (about 15 words)",
"lore": "A longer text explaining the character's background, history, and significance"
},
"items": [
{
"name": "Unique name for item 1",
"image_prompt": "Detailed visual description of item 1",
"description": "A concise description of item 1 (about 15 words)",
"lore": "A longer text explaining item 1's history and significance"
},
{
"name": "Unique name for item 2",
"image_prompt": "Detailed visual description of item 2",
"description": "A concise description of item 2 (about 15 words)",
"lore": "A longer text explaining item 2's history and significance"
},
{
"name": "Unique name for item 3",
"image_prompt": "Detailed visual description of item 3",
"description": "A concise description of item 3 (about 15 words)",
"lore": "A longer text explaining item 3's history and significance"
}
],
"style_description": "Description of the overall visual style and aesthetic"
}
Make the descriptions vivid, creative, and fitting for the character. Each image prompt should be 1-2 sentences with specific details.`,
},
{
role: 'user',
content: `Generate detailed descriptions for an action figure with the subject name: "${subject}".
Be specific about the items - they should be unique accessories that fit the character's theme and would look good as separate items in the packaging.`,
},
],
temperature: 0.8,
max_tokens: 1500,
response_format: { type: 'json_object' },
});
// Parse the generated descriptions
const content = descriptionResponse.choices[0].message.content;
try {
// Versuche, die JSON-Antwort zu parsen
generatedDescriptions = JSON.parse(content);
console.log('Raw generated descriptions:', content);
console.log(
'Parsed generated descriptions:',
JSON.stringify(generatedDescriptions, null, 2)
);
// Erstelle eine neue Struktur mit allen erforderlichen Feldern
const newStructure = {
character: {
image_prompt: '',
description: '',
lore: '',
},
items: [],
style_description: '',
};
// Fülle die Charakterinformationen aus
if (generatedDescriptions.character) {
newStructure.character.image_prompt =
generatedDescriptions.character.image_prompt ||
generatedDescriptions.character.description ||
'';
newStructure.character.description = generatedDescriptions.character.description || '';
newStructure.character.lore =
generatedDescriptions.character.lore ||
`${body.subject} has a rich history and background that influences their appearance and abilities.`;
} else if (generatedDescriptions.clothing_description) {
// Fallback für altes Format
newStructure.character.image_prompt = generatedDescriptions.clothing_description;
newStructure.character.description = `${body.subject} character with unique style and abilities.`;
newStructure.character.lore = `${body.subject} has a rich history and background that influences their appearance and abilities.`;
}
// Fülle die Items aus
if (generatedDescriptions.items && Array.isArray(generatedDescriptions.items)) {
// Neues Format mit Items-Array
newStructure.items = generatedDescriptions.items.map((item: any, index: number) => {
const itemName = item.name || `Item ${index + 1}`;
const itemDesc =
item.description || `A special item that enhances ${body.subject}'s abilities.`;
const itemImagePrompt = item.image_prompt || itemDesc;
const itemLore =
item.lore ||
`This ${itemName} has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`;
return {
name: itemName,
image_prompt: itemImagePrompt,
description: itemDesc,
lore: itemLore,
};
});
} else {
// Fallback für altes Format mit accessory-Beschreibungen
const accessoryFields = [
{ key: 'accessory1_description', name: 'Primary Item' },
{ key: 'accessory2_description', name: 'Secondary Item' },
{ key: 'accessory3_description', name: 'Tertiary Item' },
];
accessoryFields.forEach((field, index) => {
if (generatedDescriptions[field.key]) {
newStructure.items.push({
name: field.name,
image_prompt: generatedDescriptions[field.key],
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This ${field.name} has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
});
}
});
}
// Fülle die Style-Beschreibung aus
newStructure.style_description = generatedDescriptions.style_description || '';
// Ersetze die generierten Beschreibungen durch die neue Struktur
generatedDescriptions = newStructure;
console.log(
'Final processed descriptions:',
JSON.stringify(generatedDescriptions, null, 2)
);
} catch (parseError) {
console.error('Error parsing generated descriptions:', parseError);
console.log('Raw content that failed to parse:', content);
// Erstelle eine Standardstruktur, wenn das Parsen fehlschlägt
generatedDescriptions = {
character: {
image_prompt: `A detailed action figure of ${body.subject}`,
description: `${body.subject} character with unique style and abilities.`,
lore: `${body.subject} has a rich history and background that influences their appearance and abilities.`,
},
items: [
{
name: 'Primary Item',
image_prompt: `A special accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Primary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
{
name: 'Secondary Item',
image_prompt: `Another accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Secondary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
{
name: 'Tertiary Item',
image_prompt: `A third accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Tertiary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
],
style_description: `A stylish and detailed action figure of ${body.subject}`,
};
}
} catch (llmError) {
console.error('Error generating descriptions with LLM:', llmError);
// Fallback, wenn der LLM-Aufruf fehlschlägt
generatedDescriptions = {
character: {
image_prompt: `A detailed action figure of ${body.subject}`,
description: `${body.subject} character with unique style and abilities.`,
lore: `${body.subject} has a rich history and background that influences their appearance and abilities.`,
},
items: [
{
name: 'Primary Item',
image_prompt: `A special accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Primary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
{
name: 'Secondary Item',
image_prompt: `Another accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Secondary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
{
name: 'Tertiary Item',
image_prompt: `A third accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Tertiary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
],
style_description: `A stylish and detailed action figure of ${body.subject}`,
};
}
// Extract character information for use in the prompt
const characterInfo = generatedDescriptions;
// Build the enhanced prompt using the generated descriptions
console.log('Building enhanced prompt with character descriptions...');
let enhancedPrompt = `Create a photorealistic action figure of ${subject}. `;
// Add character description if available
if (characterInfo.character && characterInfo.character.image_prompt) {
enhancedPrompt += `The character should be: ${characterInfo.character.image_prompt} `;
}
// Add style description if available
if (characterInfo.style_description) {
enhancedPrompt += `The overall style should be: ${characterInfo.style_description} `;
}
// Add additional details from the request body if provided
if (body.additional_details) {
enhancedPrompt += `Additional details: ${body.additional_details} `;
}
// Add standard formatting for action figures
enhancedPrompt +=
'The action figure should be shown in a dynamic pose against a transparent background, with high-quality studio lighting to highlight details. The figure should have visible joints and articulation points typical of high-end collectible action figures. The image should be in portrait orientation with a 2:3 aspect ratio, showing the full figure.';
console.log('Enhanced prompt:', enhancedPrompt);
// Process face image if provided
let faceImage = null;
if (body.face_image_base64) {
console.log('Processing provided face image...');
// Convert base64 to binary data
const faceImageBinary = Uint8Array.from(atob(body.face_image_base64), (c) => c.charCodeAt(0));
// Create a Blob from the binary data
faceImage = new Blob([faceImageBinary], 'face_image.png', {
type: 'image/png',
});
}
// Optional step: Get base template if additional structure is needed
// For now, we use the enhanced prompt directly
const finalPrompt = enhancedPrompt;
// Store the enhanced prompt for later use
const enhancedPromptForResponse = enhancedPrompt;
// Image generation parameters with the enhanced prompt
const imageParams = {
model: 'gpt-image-1',
prompt: finalPrompt,
size: '1024x1536',
quality: 'high',
moderation: 'low',
background: 'transparent',
output_format: 'webp', // Using WebP format for better quality with transparency
n: 1,
};
// Generate image based on whether face image is provided
const imageResponse = faceImage
? await openai.images.edit({
...imageParams,
image: [faceImage],
})
: await openai.images.generate(imageParams);
// Process the image
const imageBase64 = imageResponse.data[0].b64_json;
// Generate a unique filename using timestamp and a random string
const timestamp = new Date().getTime();
const randomString = Math.random().toString(36).substring(2, 10);
const filename = `figure-${subject.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}-${randomString}.webp`;
// Convert base64 to binary data for storage
const binaryData = Uint8Array.from(atob(imageBase64), (c) => c.charCodeAt(0));
// Store the image in Supabase storage
const { data: upload, error: uploadError } = await supabase.storage
.from('figures')
.upload(filename, binaryData, {
contentType: 'image/webp',
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
console.error('Error uploading image to storage:', uploadError);
return new Response(
JSON.stringify({
error: 'Failed to upload image to storage',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
}
// Get the public URL of the uploaded image
const { data: publicUrlData } = supabase.storage.from('figures').getPublicUrl(upload.path);
// Return the public URL, enhanced prompt, generated descriptions, and metadata
return new Response(
JSON.stringify({
success: true,
image_url: publicUrlData.publicUrl,
enhanced_prompt: enhancedPromptForResponse,
generated_descriptions: characterInfo,
metadata: {
subject,
created_at: new Date().toISOString(),
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
} catch (error) {
console.error('Error:', error);
return new Response(
JSON.stringify({
error: error.message,
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
}
});

View file

@ -0,0 +1,412 @@
import OpenAI from 'https://deno.land/x/openai@v4.69.0/mod.ts';
import { createClient } from 'jsr:@supabase/supabase-js@2';
import { corsHeaders } from '../_shared/cors.ts';
/**
* Fetches a prompt template from Supabase
*/
async function fetchPromptTemplate(supabase, promptName) {
try {
const { data, error } = await supabase
.from('prompts')
.select('template')
.eq('name', promptName)
.single();
if (error) {
console.error(`Error fetching prompt '${promptName}':`, error);
return { template: null, error };
}
return { template: data.template, error: null };
} catch (error) {
console.error(`Exception fetching prompt '${promptName}':`, error);
return { template: null, error };
}
}
Deno.serve(async (req) => {
// Handle CORS preflight request
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Get environment variables
const apiKey = Deno.env.get('OPENAI_API_KEY');
const supabaseUrl = Deno.env.get('SUPABASE_URL');
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
if (!apiKey || !supabaseUrl || !supabaseKey) {
return new Response('Missing environment variables', {
status: 500,
headers: { ...corsHeaders },
});
}
// Initialize clients
const supabase = createClient(supabaseUrl, supabaseKey);
const openai = new OpenAI({
apiKey,
});
const body = await req.json();
// Validate required parameters
const subject = body.subject;
if (!subject) {
return new Response('Missing required field: subject', {
status: 400,
headers: { ...corsHeaders },
});
}
// Always generate descriptions first as a separate step
console.log('Generating character descriptions with LLM...');
let generatedDescriptions;
try {
const descriptionResponse = await openai.chat.completions.create({
model: 'gpt-4-turbo',
messages: [
{
role: 'system',
content: `You are an expert in creating detailed descriptions for action figures.
Based on the subject name, generate appropriate descriptions for the character and items (accessories).
For the character, provide a detailed description, a short summary, and a lore background.
For the items, be very specific and creative. Each item should be unique and thematically appropriate
for the character. Think of items that would enhance the character's abilities, reflect their personality, or
complement their story. Items should be visually interesting and varied in size and function.
For each item, provide:
1. A unique and fitting name
2. An image prompt (detailed visual description for image generation)
3. A short description (about 15 words)
4. A lore background (longer text explaining the item's history and significance)
Return your response as a JSON object with the following structure:
{
"character": {
"image_prompt": "Detailed visual description of the character with clothing, colors, materials, and style",
"description": "A concise description of the character (about 15 words)",
"lore": "A longer text explaining the character's background, history, and significance"
},
"items": [
{
"name": "Unique name for item 1",
"image_prompt": "Detailed visual description of item 1",
"description": "A concise description of item 1 (about 15 words)",
"lore": "A longer text explaining item 1's history and significance"
},
{
"name": "Unique name for item 2",
"image_prompt": "Detailed visual description of item 2",
"description": "A concise description of item 2 (about 15 words)",
"lore": "A longer text explaining item 2's history and significance"
},
{
"name": "Unique name for item 3",
"image_prompt": "Detailed visual description of item 3",
"description": "A concise description of item 3 (about 15 words)",
"lore": "A longer text explaining item 3's history and significance"
}
],
"style_description": "Description of the overall visual style and aesthetic"
}
Make the descriptions vivid, creative, and fitting for the character. Each image prompt should be 1-2 sentences with specific details.`,
},
{
role: 'user',
content: `Generate detailed descriptions for an action figure with the subject name: "${subject}".
Be specific about the items - they should be unique accessories that fit the character's theme and would look good as separate items in the packaging.`,
},
],
temperature: 0.8,
max_tokens: 1500,
response_format: { type: 'json_object' },
});
// Parse the generated descriptions
const content = descriptionResponse.choices[0].message.content;
try {
// Versuche, die JSON-Antwort zu parsen
generatedDescriptions = JSON.parse(content);
console.log('Raw generated descriptions:', content);
console.log(
'Parsed generated descriptions:',
JSON.stringify(generatedDescriptions, null, 2)
);
// Erstelle eine neue Struktur mit allen erforderlichen Feldern
const newStructure = {
character: {
image_prompt: '',
description: '',
lore: '',
},
items: [],
style_description: '',
};
// Fülle die Charakterinformationen aus
if (generatedDescriptions.character) {
newStructure.character.image_prompt =
generatedDescriptions.character.image_prompt ||
generatedDescriptions.character.description ||
'';
newStructure.character.description = generatedDescriptions.character.description || '';
newStructure.character.lore =
generatedDescriptions.character.lore ||
`${body.subject} has a rich history and background that influences their appearance and abilities.`;
} else if (generatedDescriptions.clothing_description) {
// Fallback für altes Format
newStructure.character.image_prompt = generatedDescriptions.clothing_description;
newStructure.character.description = `${body.subject} character with unique style and abilities.`;
newStructure.character.lore = `${body.subject} has a rich history and background that influences their appearance and abilities.`;
}
// Fülle die Items aus
if (generatedDescriptions.items && Array.isArray(generatedDescriptions.items)) {
// Neues Format mit Items-Array
newStructure.items = generatedDescriptions.items.map((item: any, index: number) => {
const itemName = item.name || `Item ${index + 1}`;
const itemDesc =
item.description || `A special item that enhances ${body.subject}'s abilities.`;
const itemImagePrompt = item.image_prompt || itemDesc;
const itemLore =
item.lore ||
`This ${itemName} has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`;
return {
name: itemName,
image_prompt: itemImagePrompt,
description: itemDesc,
lore: itemLore,
};
});
} else {
// Fallback für altes Format mit accessory-Beschreibungen
const accessoryFields = [
{ key: 'accessory1_description', name: 'Primary Item' },
{ key: 'accessory2_description', name: 'Secondary Item' },
{ key: 'accessory3_description', name: 'Tertiary Item' },
];
accessoryFields.forEach((field, index) => {
if (generatedDescriptions[field.key]) {
newStructure.items.push({
name: field.name,
image_prompt: generatedDescriptions[field.key],
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This ${field.name} has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
});
}
});
}
// Fülle die Style-Beschreibung aus
newStructure.style_description = generatedDescriptions.style_description || '';
// Ersetze die generierten Beschreibungen durch die neue Struktur
generatedDescriptions = newStructure;
console.log(
'Final processed descriptions:',
JSON.stringify(generatedDescriptions, null, 2)
);
} catch (parseError) {
console.error('Error parsing generated descriptions:', parseError);
console.log('Raw content that failed to parse:', content);
// Erstelle eine Standardstruktur, wenn das Parsen fehlschlägt
generatedDescriptions = {
character: {
image_prompt: `A detailed action figure of ${body.subject}`,
description: `${body.subject} character with unique style and abilities.`,
lore: `${body.subject} has a rich history and background that influences their appearance and abilities.`,
},
items: [
{
name: 'Primary Item',
image_prompt: `A special accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Primary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
{
name: 'Secondary Item',
image_prompt: `Another accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Secondary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
{
name: 'Tertiary Item',
image_prompt: `A third accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Tertiary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
],
style_description: `A stylish and detailed action figure of ${body.subject}`,
};
}
} catch (llmError) {
console.error('Error generating descriptions with LLM:', llmError);
// Fallback, wenn der LLM-Aufruf fehlschlägt
generatedDescriptions = {
character: {
image_prompt: `A detailed action figure of ${body.subject}`,
description: `${body.subject} character with unique style and abilities.`,
lore: `${body.subject} has a rich history and background that influences their appearance and abilities.`,
},
items: [
{
name: 'Primary Item',
image_prompt: `A special accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Primary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
{
name: 'Secondary Item',
image_prompt: `Another accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Secondary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
{
name: 'Tertiary Item',
image_prompt: `A third accessory for ${body.subject}`,
description: `A special item that enhances ${body.subject}'s abilities.`,
lore: `This Tertiary Item has special significance in ${body.subject}'s story. It represents an important aspect of their character and journey.`,
},
],
style_description: `A stylish and detailed action figure of ${body.subject}`,
};
}
// Extract character information for use in the prompt
const characterInfo = generatedDescriptions;
// Build the enhanced prompt using the generated descriptions
console.log('Building enhanced prompt with character descriptions...');
let enhancedPrompt = `Create a photorealistic action figure of ${subject}. `;
// Add character description if available
if (characterInfo.character && characterInfo.character.image_prompt) {
enhancedPrompt += `The character should be: ${characterInfo.character.image_prompt} `;
}
// Add style description if available
if (characterInfo.style_description) {
enhancedPrompt += `The overall style should be: ${characterInfo.style_description} `;
}
// Add additional details from the request body if provided
if (body.additional_details) {
enhancedPrompt += `Additional details: ${body.additional_details} `;
}
// Add standard formatting for action figures
enhancedPrompt +=
'The action figure should be shown in a dynamic pose against a transparent background, with high-quality studio lighting to highlight details. The figure should have visible joints and articulation points typical of high-end collectible action figures. The image should be in portrait orientation with a 2:3 aspect ratio, showing the full figure.';
console.log('Enhanced prompt:', enhancedPrompt);
// Process face image if provided
let faceImage = null;
if (body.face_image_base64) {
console.log('Processing provided face image...');
// Convert base64 to binary data
const faceImageBinary = Uint8Array.from(atob(body.face_image_base64), (c) => c.charCodeAt(0));
// Create a Blob from the binary data
faceImage = new Blob([faceImageBinary], 'face_image.png', {
type: 'image/png',
});
}
// Optional step: Get base template if additional structure is needed
// For now, we use the enhanced prompt directly
const finalPrompt = enhancedPrompt;
// Store the enhanced prompt for later use
const enhancedPromptForResponse = enhancedPrompt;
// Image generation parameters with the enhanced prompt
const imageParams = {
model: 'gpt-image-1',
prompt: finalPrompt,
size: '1024x1536',
quality: 'high',
moderation: 'low',
background: 'transparent',
output_format: 'webp', // Using WebP format for better quality with transparency
n: 1,
};
// Generate image based on whether face image is provided
const imageResponse = faceImage
? await openai.images.edit({
...imageParams,
image: [faceImage],
})
: await openai.images.generate(imageParams);
// Process the image
const imageBase64 = imageResponse.data[0].b64_json;
// Generate a unique filename using timestamp and a random string
const timestamp = new Date().getTime();
const randomString = Math.random().toString(36).substring(2, 10);
const filename = `wtfigure-${subject.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}-${randomString}.webp`;
// Convert base64 to binary data for storage
const binaryData = Uint8Array.from(atob(imageBase64), (c) => c.charCodeAt(0));
// Store the image in Supabase storage
const { data: upload, error: uploadError } = await supabase.storage
.from('figures')
.upload(filename, binaryData, {
contentType: 'image/webp',
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
console.error('Error uploading image to storage:', uploadError);
return new Response(
JSON.stringify({
error: 'Failed to upload image to storage',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
}
// Get the public URL of the uploaded image
const { data: publicUrlData } = supabase.storage.from('figures').getPublicUrl(upload.path);
// Return the public URL, enhanced prompt, generated descriptions, and metadata
return new Response(
JSON.stringify({
success: true,
image_url: publicUrlData.publicUrl,
enhanced_prompt: enhancedPromptForResponse,
generated_descriptions: characterInfo,
metadata: {
subject,
created_at: new Date().toISOString(),
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
} catch (error) {
console.error('Error:', error);
return new Response(
JSON.stringify({
error: error.message,
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
}
});

View file

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
extend: {},
},
plugins: [],
};

View file

@ -0,0 +1,12 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"~/*": ["*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
}

View file

@ -0,0 +1,89 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import { Session, User } from '@supabase/supabase-js';
import { supabase } from './supabase';
type AuthContextType = {
user: User | null;
session: Session | null;
loading: boolean;
signUp: (email: string, password: string) => Promise<{ error: any }>;
signIn: (email: string, password: string) => Promise<{ error: any }>;
signOut: () => Promise<{ error: any }>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Lade den aktuellen Benutzer beim Start
const loadUser = async () => {
const { data } = await supabase.auth.getSession();
setSession(data.session);
setUser(data.session?.user || null);
setLoading(false);
// Abonniere Authentifizierungsänderungen
const { data: authListener } = supabase.auth.onAuthStateChange(async (event, session) => {
setSession(session);
setUser(session?.user || null);
setLoading(false);
});
return () => {
authListener.subscription.unsubscribe();
};
};
loadUser();
}, []);
const signUp = async (email: string, password: string) => {
try {
const { error } = await supabase.auth.signUp({ email, password });
return { error };
} catch (error) {
return { error };
}
};
const signIn = async (email: string, password: string) => {
try {
const { error } = await supabase.auth.signInWithPassword({ email, password });
return { error };
} catch (error) {
return { error };
}
};
const signOut = async () => {
try {
const { error } = await supabase.auth.signOut();
return { error };
} catch (error) {
return { error };
}
};
const value = {
user,
session,
loading,
signUp,
signIn,
signOut,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View file

@ -0,0 +1,105 @@
import React, { ErrorInfo } from 'react';
import { View, Text, StyleSheet } from 'react-native';
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error) {
// Aktualisiere den State, sodass der nächste Render den Fallback-UI zeigt.
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Fehlerdetails können auch an einen Fehlerprotokollierungsdienst gesendet werden
console.error('Unbehandelter Fehler:', error);
console.error('Komponenten-Stack:', errorInfo.componentStack);
this.setState({
error,
errorInfo,
});
}
render() {
if (this.state.hasError) {
// Fallback-UI für Fehler
return (
<View style={styles.container}>
<Text style={styles.title}>Etwas ist schiefgelaufen</Text>
<Text style={styles.errorText}>{this.state.error?.toString()}</Text>
<Text style={styles.stackText}>{this.state.errorInfo?.componentStack}</Text>
</View>
);
}
return this.props.children;
}
}
// Globaler Fehlerhandler für nicht abgefangene Fehler
export const setupGlobalErrorHandler = () => {
// Fehlerhandler für unbehandelte Versprechen
const handlePromiseRejection = (event: any) => {
console.error('Unbehandelter Promise-Fehler:', event);
};
// Fehlerhandler für globale Fehler
const handleGlobalError = (error: any, isFatal: boolean) => {
console.error(`Globaler ${isFatal ? 'fataler ' : ''}Fehler:`, error);
};
// Registriere die Handler, wenn wir in einer React Native-Umgebung sind
if (typeof global !== 'undefined' && global.hasOwnProperty('ErrorUtils')) {
// @ts-ignore - ErrorUtils existiert in React Native, aber nicht in TypeScript-Definitionen
global.ErrorUtils.setGlobalHandler(handleGlobalError);
}
// Für Versprechen-Fehler
if (typeof global.addEventListener === 'function') {
global.addEventListener('unhandledrejection', handlePromiseRejection);
}
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
backgroundColor: '#f8f9fa',
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 10,
color: '#dc3545',
},
errorText: {
fontSize: 16,
marginBottom: 10,
color: '#343a40',
},
stackText: {
fontSize: 14,
color: '#6c757d',
},
});
export default ErrorBoundary;

View file

@ -0,0 +1,142 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Theme, ThemeName, ThemeVariant, getTheme } from './themes';
// Keys for AsyncStorage
const THEME_NAME_KEY = 'wtfigure_theme_name';
const THEME_VARIANT_KEY = 'wtfigure_theme_variant';
const THEME_MODE_KEY = 'wtfigure_theme_mode';
const DEBUG_BORDERS_KEY = 'wtfigure_debug_borders';
// Theme mode (auto uses system, or manual override)
export type ThemeMode = 'system' | 'light' | 'dark';
// Context type definition
type ThemeContextType = {
theme: Theme;
themeName: ThemeName;
themeVariant: ThemeVariant;
themeMode: ThemeMode;
isDark: boolean;
debugBorders: boolean;
setThemeName: (name: ThemeName) => void;
setThemeMode: (mode: ThemeMode) => void;
setDebugBorders: (enabled: boolean) => void;
};
// Create the context with default values
const ThemeContext = createContext<ThemeContextType>({
theme: getTheme('default', 'light'),
themeName: 'default',
themeVariant: 'light',
themeMode: 'system',
isDark: false,
debugBorders: false,
setThemeName: () => {},
setThemeMode: () => {},
setDebugBorders: () => {},
});
// Hook to use the theme context
export const useTheme = () => useContext(ThemeContext);
// Provider component
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Get the system color scheme
const systemColorScheme = useColorScheme();
// State for theme settings
const [themeName, setThemeNameState] = useState<ThemeName>('default');
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
const [debugBorders, setDebugBordersState] = useState<boolean>(false);
// Compute the current theme variant based on mode and system settings
const themeVariant: ThemeVariant =
themeMode === 'system'
? systemColorScheme === 'dark'
? 'dark'
: 'light'
: (themeMode as ThemeVariant);
// Compute the current theme
const theme = getTheme(themeName, themeVariant);
// Determine if we're in dark mode
const isDark = themeVariant === 'dark';
// Load saved preferences from AsyncStorage
useEffect(() => {
const loadThemePreferences = async () => {
try {
const savedThemeName = await AsyncStorage.getItem(THEME_NAME_KEY);
const savedThemeMode = await AsyncStorage.getItem(THEME_MODE_KEY);
const savedDebugBorders = await AsyncStorage.getItem(DEBUG_BORDERS_KEY);
if (savedThemeName) {
setThemeNameState(savedThemeName as ThemeName);
}
if (savedThemeMode) {
setThemeModeState(savedThemeMode as ThemeMode);
}
if (savedDebugBorders) {
setDebugBordersState(savedDebugBorders === 'true');
}
} catch (error) {
console.error('Failed to load theme preferences:', error);
}
};
loadThemePreferences();
}, []);
// Function to set theme name and save to AsyncStorage
const setThemeName = async (name: ThemeName) => {
try {
await AsyncStorage.setItem(THEME_NAME_KEY, name);
setThemeNameState(name);
} catch (error) {
console.error('Failed to save theme name:', error);
}
};
// Function to set theme mode and save to AsyncStorage
const setThemeMode = async (mode: ThemeMode) => {
try {
await AsyncStorage.setItem(THEME_MODE_KEY, mode);
setThemeModeState(mode);
} catch (error) {
console.error('Failed to save theme mode:', error);
}
};
// Function to set debug borders and save to AsyncStorage
const setDebugBorders = async (enabled: boolean) => {
try {
await AsyncStorage.setItem(DEBUG_BORDERS_KEY, enabled.toString());
setDebugBordersState(enabled);
} catch (error) {
console.error('Failed to save debug borders setting:', error);
}
};
return (
<ThemeContext.Provider
value={{
theme,
themeName,
themeVariant,
themeMode,
isDark,
debugBorders,
setThemeName,
setThemeMode,
setDebugBorders,
}}
>
{children}
</ThemeContext.Provider>
);
};

View file

@ -0,0 +1,4 @@
// Statische Konfigurationswerte für die Produktion
export const SUPABASE_URL = 'https://igxexenivpvivtqkweup.supabase.co';
export const SUPABASE_ANON_KEY =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlneGV4ZW5pdnB2aXZ0cWt3ZXVwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDU2NzYzNTMsImV4cCI6MjA2MTI1MjM1M30.XmwmZo8dts6z_wONHS1hn3nd-P2IomNnhSMks_otm3M';

View file

@ -0,0 +1,444 @@
import { supabase } from './supabase';
import * as FileSystem from 'expo-file-system';
import { ExtendedFigureData } from '../components/CreateFigureForm';
/**
* Generates a figure using the Edge Function and stores it in the database
*/
export async function generateFigure(formData: ExtendedFigureData, isPublic: boolean = true) {
try {
// Convert image to Base64 if available
let faceImageBase64 = null;
if (formData.characterImage) {
// For web
if (formData.characterImage.startsWith('data:')) {
faceImageBase64 = formData.characterImage.split(',')[1];
}
// For native platforms
else {
const base64 = await FileSystem.readAsStringAsync(formData.characterImage, {
encoding: FileSystem.EncodingType.Base64,
});
faceImageBase64 = base64;
}
}
// Prepare payload for the Edge Function with the new JSONB structure
// Die Edge-Funktion wird die vollständige JSONB-Struktur generieren, wenn Felder fehlen
const payload = {
subject: formData.name,
rarity: formData.rarity || 'common',
face_image: faceImageBase64,
// Wir können optional eine vordefinierte JSONB-Struktur mitgeben, wenn wir bestimmte Werte haben
character_info: {
character: {
image_prompt: formData.characterDescription || '',
// Diese Felder werden von der Edge-Funktion generiert, wenn sie fehlen
description: '',
lore: '',
},
items: [
{
name: formData.artifacts[0]?.name || '',
image_prompt: formData.artifacts[0]?.description || '',
description: '',
lore: '',
},
{
name: formData.artifacts[1]?.name || '',
image_prompt: formData.artifacts[1]?.description || '',
description: '',
lore: '',
},
{
name: formData.artifacts[2]?.name || '',
image_prompt: formData.artifacts[2]?.description || '',
description: '',
lore: '',
},
],
},
};
// Prepare a complete payload with all necessary data
const cleanPayload = {
subject: formData.name, // This is the only required field
rarity: formData.rarity || 'common',
face_image: faceImageBase64,
character_info: {
character: {
image_prompt: formData.characterDescription || '',
description: formData.characterDescription || '',
lore: '',
},
items: formData.artifacts.map((artifact) => ({
name: artifact.name || '',
image_prompt: artifact.description || '',
description: artifact.description || '',
lore: '',
})),
},
};
// Validate payload before sending
if (!cleanPayload.subject) {
throw new Error('Error: Name/Subject is required');
}
// Log payload to see what is being sent
console.log('Sending payload to Edge Function:', JSON.stringify(cleanPayload));
console.log('Payload as string:', JSON.stringify(cleanPayload));
// Call Edge Function with adjusted options for web environments
console.log('Calling Edge Function...');
// Variable for the Edge Function response
let edgeFunctionResponse;
// Use supabase.functions.invoke directly - this handles authentication properly
console.log('Using supabase.functions.invoke...');
console.log('Payload being sent to Edge Function:', JSON.stringify(cleanPayload, null, 2));
let edgeFunctionData = null;
let edgeFunctionError = null;
try {
// Stelle sicher, dass wir einen gültigen Supabase-Client haben
const { data: sessionData } = await supabase.auth.getSession();
if (!sessionData.session) {
console.error('No active session found');
} else {
console.log('Session found, user is authenticated');
}
// Verwende das vollständige Payload mit allen Informationen
console.log('Using complete payload with character info');
// Get the access token for authorization
const accessToken = sessionData?.session?.access_token;
if (!accessToken) {
throw new Error('No access token available. Please log in again.');
}
// Use the known Supabase URL from the error logs
const supabaseUrl = 'https://igxexenivpvivtqkweup.supabase.co';
// Get the anon key using the auth client
const {
data: { session },
} = await supabase.auth.getSession();
const supabaseKey = session?.access_token || '';
// Use direct fetch approach instead of supabase.functions.invoke
const functionUrl = `${supabaseUrl}/functions/v1/barbiebox-generator`;
console.log('Calling Edge Function at URL:', functionUrl);
const response = await fetch(functionUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
apikey: supabaseKey,
},
body: JSON.stringify(cleanPayload),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Edge Function error response:', errorText);
throw new Error(`Edge Function returned status ${response.status}: ${errorText}`);
}
const data = await response.json();
const error = null;
edgeFunctionData = data;
edgeFunctionError = error;
console.log('Edge Function response:', data);
if (error) {
console.error('Edge Function error details:', error);
}
} catch (invokeError) {
console.error('Exception during Edge Function invoke:', invokeError);
throw invokeError;
}
if (edgeFunctionError) {
console.error('Error calling Edge Function:', edgeFunctionError);
throw edgeFunctionError;
}
edgeFunctionResponse = edgeFunctionData;
// Check if the Edge Function response is valid
if (!edgeFunctionResponse || !edgeFunctionResponse.image_url) {
throw new Error('The Edge Function did not return a valid image URL');
}
console.log('Storing figure in database with image URL:', edgeFunctionResponse.image_url);
// Get the user ID
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError) {
console.error('Error retrieving user:', userError);
throw new Error('User could not be retrieved: ' + userError.message);
}
const userId = userData.user?.id;
if (!userId) {
throw new Error('User ID could not be determined');
}
// Verwende die generierten Beschreibungen von der Edge-Funktion
const generatedDescriptions = edgeFunctionResponse.generated_descriptions;
// Ausführliches Debugging der empfangenen Daten
console.log('FULL Edge Function Response:', JSON.stringify(edgeFunctionResponse, null, 2));
console.log(
'Received generated descriptions from Edge Function:',
JSON.stringify(generatedDescriptions, null, 2)
);
// Prüfe die Struktur der generierten Beschreibungen
if (generatedDescriptions) {
console.log('Generated descriptions structure check:');
console.log('- Has character:', !!generatedDescriptions.character);
if (generatedDescriptions.character) {
console.log(' - Character fields:', Object.keys(generatedDescriptions.character));
console.log(' - Has image_prompt:', !!generatedDescriptions.character.image_prompt);
console.log(' - Has description:', !!generatedDescriptions.character.description);
console.log(' - Has lore:', !!generatedDescriptions.character.lore);
}
console.log('- Has items:', !!generatedDescriptions.items);
if (generatedDescriptions.items && Array.isArray(generatedDescriptions.items)) {
console.log(' - Items count:', generatedDescriptions.items.length);
generatedDescriptions.items.forEach((item: any, index: number) => {
console.log(` - Item ${index + 1} fields:`, Object.keys(item));
console.log(` - Has name:`, !!item.name);
console.log(` - Has image_prompt:`, !!item.image_prompt);
console.log(` - Has description:`, !!item.description);
console.log(` - Has lore:`, !!item.lore);
});
}
}
// Erstelle das character_info JSONB-Objekt basierend auf der neuen Struktur
let characterInfo: any;
// Prüfe, ob die Edge-Funktion die neue JSONB-Struktur zurückgegeben hat
if (generatedDescriptions && generatedDescriptions.character && generatedDescriptions.items) {
// Verwende die vollständige JSONB-Struktur von der Edge-Funktion
console.log('Using new JSONB structure from Edge Function');
// Stelle sicher, dass alle erweiterten Felder vorhanden sind
const character = {
description:
generatedDescriptions.character.description || formData.characterDescription || '',
image_prompt:
generatedDescriptions.character.image_prompt ||
generatedDescriptions.character.description ||
formData.characterDescription ||
'',
lore:
generatedDescriptions.character.lore ||
`${formData.name} has a rich history and background.`,
};
// Stelle sicher, dass alle Items die erweiterten Felder haben
const items = generatedDescriptions.items.map((item: any, index: number) => {
const itemName = item.name || `Item ${index + 1}`;
const itemDesc = item.description || formData.artifacts[index]?.description || '';
return {
name: itemName,
description: itemDesc,
image_prompt: item.image_prompt || itemDesc,
lore: item.lore || `This item has special significance for ${formData.name}.`,
};
});
characterInfo = {
character,
items,
style_description: generatedDescriptions.style_description || '',
};
// Logge die finale Struktur
console.log(
'Final character_info structure to be saved:',
JSON.stringify(characterInfo, null, 2)
);
} else {
// Fallback auf die alte Struktur (sollte nicht mehr vorkommen)
console.log('WARNING: Edge Function returned old format, creating JSONB structure manually');
characterInfo = {
character: {
description:
formData.characterDescription ||
(generatedDescriptions ? generatedDescriptions.clothing_description : ''),
image_prompt:
formData.characterDescription ||
(generatedDescriptions ? generatedDescriptions.clothing_description : ''),
lore: `${formData.name} has a rich history and background.`,
},
items: [
{
name: 'Item 1',
description:
formData.artifacts[0]?.description ||
(generatedDescriptions ? generatedDescriptions.accessory1_description : ''),
image_prompt:
formData.artifacts[0]?.description ||
(generatedDescriptions ? generatedDescriptions.accessory1_description : ''),
lore: `This item has special significance for ${formData.name}.`,
},
{
name: 'Item 2',
description:
formData.artifacts[1]?.description ||
(generatedDescriptions ? generatedDescriptions.accessory2_description : ''),
image_prompt:
formData.artifacts[1]?.description ||
(generatedDescriptions ? generatedDescriptions.accessory2_description : ''),
lore: `This item has special significance for ${formData.name}.`,
},
{
name: 'Item 3',
description:
formData.artifacts[2]?.description ||
(generatedDescriptions ? generatedDescriptions.accessory3_description : ''),
image_prompt:
formData.artifacts[2]?.description ||
(generatedDescriptions ? generatedDescriptions.accessory3_description : ''),
lore: `This item has special significance for ${formData.name}.`,
},
],
};
}
// Store figure in the database
const { data: figureData, error: figureError } = await supabase
.from('figures')
.insert({
name: formData.name,
subject: formData.name,
image_url: edgeFunctionResponse.image_url,
enhanced_prompt: edgeFunctionResponse.enhanced_prompt, // Store the enhanced prompt
rarity: payload.rarity, // Added rarity field
character_info: characterInfo, // Verwende das neue JSONB-Feld
is_public: isPublic,
is_archived: false, // Added is_archived field
user_id: userId,
})
.select()
.single();
if (figureError) {
console.error('Error saving the figure:', figureError);
throw new Error(figureError.message);
}
console.log('Figure successfully saved in the database:', figureData);
return figureData;
} catch (error) {
console.error('Error in generateFigure:', error);
throw error;
}
}
/**
* Loads a user's figures from the database
*/
export async function getUserFigures(userId: string) {
try {
const { data, error } = await supabase
.from('figures')
.select(
`
id,
name,
subject,
image_url,
likes,
is_public,
rarity,
is_archived,
character_info
`
)
.eq('user_id', userId)
.eq('is_archived', false) // Only show non-archived figures
.order('created_at', { ascending: false });
if (error) {
console.error('Error loading figures:', error);
throw new Error(error.message);
}
return data || [];
} catch (error) {
console.error('Error in getUserFigures:', error);
throw error;
}
}
/**
* Loads public figures from the database
*/
export async function getPublicFigures() {
try {
const { data, error } = await supabase
.from('figures')
.select(
`
id,
name,
subject,
image_url,
enhanced_prompt,
likes,
rarity,
user_id,
created_at,
character_info
`
)
.eq('is_public', true)
.eq('is_archived', false) // Only show non-archived figures
.order('created_at', { ascending: false });
if (error) {
console.error('Error loading public figures:', error);
throw new Error(error.message);
}
return data || [];
} catch (error) {
console.error('Error in getPublicFigures:', error);
throw error;
}
}
/**
* Aktualisiert die Likes einer Figur
*/
export async function likeFigure(figureId: number) {
try {
const { data, error } = await supabase.rpc('increment_likes', {
figure_id: figureId,
});
if (error) {
console.error('Fehler beim Liken der Figur:', error);
throw new Error(error.message);
}
return data;
} catch (error) {
console.error('Fehler in likeFigure:', error);
throw error;
}
}

View file

@ -0,0 +1,42 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config';
// Verwende die statischen Werte aus der Konfigurationsdatei
const supabaseUrl = SUPABASE_URL;
const supabaseAnonKey = SUPABASE_ANON_KEY;
// Custom storage implementation for React Native
const ExpoAsyncStorage = {
getItem: async (key: string) => {
try {
return await AsyncStorage.getItem(key);
} catch (error) {
console.error('Error getting item from AsyncStorage:', error);
return null;
}
},
setItem: async (key: string, value: string) => {
try {
await AsyncStorage.setItem(key, value);
} catch (error) {
console.error('Error setting item in AsyncStorage:', error);
}
},
removeItem: async (key: string) => {
try {
await AsyncStorage.removeItem(key);
} catch (error) {
console.error('Error removing item from AsyncStorage:', error);
}
},
};
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: ExpoAsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

View file

@ -0,0 +1,150 @@
// Theme definitions for the WTFigure app
// Each theme has a light and dark variant
export type ThemeColors = {
// Base colors
background: string;
text: string;
primary: string;
secondary: string;
accent: string;
// UI elements
card: string;
border: string;
input: string;
inputActive: string;
// Status colors
success: string;
warning: string;
error: string;
};
export type ThemeVariant = 'light' | 'dark';
export type ThemeName = 'default' | 'pastel' | 'vibrant';
export type ThemeMode = 'system' | 'light' | 'dark';
export type Theme = {
name: ThemeName;
variant: ThemeVariant;
colors: ThemeColors;
};
// Default theme
const defaultLightTheme: ThemeColors = {
background: '#ffffff',
text: '#333333',
primary: '#ff69b4', // Pink
secondary: '#87ceeb', // Sky blue
accent: '#ffb6c1', // Light pink
card: '#f8f8f8',
border: '#e0e0e0',
input: '#f5f5f5',
inputActive: '#ffffff',
success: '#4caf50',
warning: '#ff9800',
error: '#f44336',
};
const defaultDarkTheme: ThemeColors = {
background: '#121212',
text: '#f5f5f5',
primary: '#ff69b4', // Pink
secondary: '#4a90e2', // Blue
accent: '#d81b60', // Deep pink
card: '#1e1e1e',
border: '#333333',
input: '#2c2c2c',
inputActive: '#3a3a3a',
success: '#4caf50',
warning: '#ff9800',
error: '#f44336',
};
// Pastel theme
const pastelLightTheme: ThemeColors = {
background: '#f8f5f2',
text: '#5d534f',
primary: '#d4a5a5', // Pastel pink
secondary: '#a5c0d4', // Pastel blue
accent: '#a5d4a5', // Pastel green
card: '#ffffff',
border: '#e8e0d8',
input: '#f0ebe6',
inputActive: '#ffffff',
success: '#a5d4a5',
warning: '#d4c7a5',
error: '#d4a5a5',
};
const pastelDarkTheme: ThemeColors = {
background: '#2d2a2e',
text: '#e8e0d8',
primary: '#c9a0a0', // Dark pastel pink
secondary: '#a0b8c9', // Dark pastel blue
accent: '#a0c9a0', // Dark pastel green
card: '#3a3639',
border: '#4a464a',
input: '#3a3639',
inputActive: '#4a4649',
success: '#a0c9a0',
warning: '#c9bda0',
error: '#c9a0a0',
};
// Vibrant theme
const vibrantLightTheme: ThemeColors = {
background: '#ffffff',
text: '#1a1a1a',
primary: '#ff1493', // Deep pink
secondary: '#00bfff', // Deep sky blue
accent: '#32cd32', // Lime green
card: '#f0f0f0',
border: '#d0d0d0',
input: '#f8f8f8',
inputActive: '#ffffff',
success: '#00cc44',
warning: '#ffcc00',
error: '#ff3333',
};
const vibrantDarkTheme: ThemeColors = {
background: '#0a0a0a',
text: '#ffffff',
primary: '#ff1493', // Deep pink
secondary: '#00bfff', // Deep sky blue
accent: '#32cd32', // Lime green
card: '#1a1a1a',
border: '#2a2a2a',
input: '#1f1f1f',
inputActive: '#2a2a2a',
success: '#00cc44',
warning: '#ffcc00',
error: '#ff3333',
};
// Theme collections
export const themes: Record<ThemeName, Record<ThemeVariant, ThemeColors>> = {
default: {
light: defaultLightTheme,
dark: defaultDarkTheme,
},
pastel: {
light: pastelLightTheme,
dark: pastelDarkTheme,
},
vibrant: {
light: vibrantLightTheme,
dark: vibrantDarkTheme,
},
};
// Helper function to get a theme
export function getTheme(name: ThemeName, variant: ThemeVariant): Theme {
return {
name,
variant,
colors: themes[name][variant],
};
}