mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
🎮 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:
parent
03b77eec46
commit
949b9c85bc
70 changed files with 7769 additions and 0 deletions
25
games/figgos/.gitignore
vendored
Normal file
25
games/figgos/.gitignore
vendored
Normal 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*
|
||||
98
games/figgos/Readmes/BackendArchitecture.md
Normal file
98
games/figgos/Readmes/BackendArchitecture.md
Normal 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.
|
||||
467
games/figgos/Readmes/CreatePageIntegration.md
Normal file
467
games/figgos/Readmes/CreatePageIntegration.md
Normal 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
|
||||
238
games/figgos/Readmes/GPTImageAPI.md
Normal file
238
games/figgos/Readmes/GPTImageAPI.md
Normal 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"
|
||||
261
games/figgos/Readmes/SupabaseMCPConnect.md
Normal file
261
games/figgos/Readmes/SupabaseMCPConnect.md
Normal 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
2
games/figgos/app-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
58
games/figgos/app.json
Normal file
58
games/figgos/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
games/figgos/app/(auth)/_layout.tsx
Normal file
21
games/figgos/app/(auth)/_layout.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
596
games/figgos/app/(auth)/login.tsx
Normal file
596
games/figgos/app/(auth)/login.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
276
games/figgos/app/(auth)/register.tsx
Normal file
276
games/figgos/app/(auth)/register.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
87
games/figgos/app/(tabs)/_layout.tsx
Normal file
87
games/figgos/app/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
games/figgos/app/(tabs)/create.tsx
Normal file
156
games/figgos/app/(tabs)/create.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
278
games/figgos/app/(tabs)/index.tsx
Normal file
278
games/figgos/app/(tabs)/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
207
games/figgos/app/(tabs)/shelf.tsx
Normal file
207
games/figgos/app/(tabs)/shelf.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
46
games/figgos/app/+html.tsx
Normal file
46
games/figgos/app/+html.tsx
Normal 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;
|
||||
}
|
||||
}`;
|
||||
23
games/figgos/app/+not-found.tsx
Normal file
23
games/figgos/app/+not-found.tsx
Normal 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]`,
|
||||
};
|
||||
64
games/figgos/app/_layout.tsx
Normal file
64
games/figgos/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
games/figgos/app/modal.tsx
Normal file
13
games/figgos/app/modal.tsx
Normal 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'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
371
games/figgos/app/settings.tsx
Normal file
371
games/figgos/app/settings.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
34
games/figgos/app/subscription.tsx
Normal file
34
games/figgos/app/subscription.tsx
Normal 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;
|
||||
BIN
games/figgos/assets/actionfigures/YourCharacter.png
Normal file
BIN
games/figgos/assets/actionfigures/YourCharacter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
BIN
games/figgos/assets/adaptive-icon.png
Normal file
BIN
games/figgos/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
games/figgos/assets/favicon.png
Normal file
BIN
games/figgos/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
games/figgos/assets/icon.png
Normal file
BIN
games/figgos/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
32
games/figgos/assets/icons/MoreVerticalIcon.tsx
Normal file
32
games/figgos/assets/icons/MoreVerticalIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
games/figgos/assets/icons/index.ts
Normal file
1
games/figgos/assets/icons/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './MoreVerticalIcon';
|
||||
BIN
games/figgos/assets/splash.png
Normal file
BIN
games/figgos/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
games/figgos/assets/videos/WTFigure-9x16.mov
Normal file
BIN
games/figgos/assets/videos/WTFigure-9x16.mov
Normal file
Binary file not shown.
10
games/figgos/babel.config.js
Normal file
10
games/figgos/babel.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
const plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
40
games/figgos/cesconfig.json
Normal file
40
games/figgos/cesconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
games/figgos/components/Button.tsx
Normal file
45
games/figgos/components/Button.tsx
Normal 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
|
||||
520
games/figgos/components/CreateFigureForm.tsx
Normal file
520
games/figgos/components/CreateFigureForm.tsx
Normal 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;
|
||||
52
games/figgos/components/ErrorMessage.tsx
Normal file
52
games/figgos/components/ErrorMessage.tsx
Normal 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;
|
||||
312
games/figgos/components/FigureCard.tsx
Normal file
312
games/figgos/components/FigureCard.tsx
Normal 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');
|
||||
84
games/figgos/components/FigureCardInfo.tsx
Normal file
84
games/figgos/components/FigureCardInfo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
188
games/figgos/components/FigureInfoModal.tsx
Normal file
188
games/figgos/components/FigureInfoModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
144
games/figgos/components/Header.tsx
Normal file
144
games/figgos/components/Header.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
25
games/figgos/components/ScreenContent.tsx
Normal file
25
games/figgos/components/ScreenContent.tsx
Normal 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`,
|
||||
};
|
||||
31
games/figgos/components/TabBarIcon.tsx
Normal file
31
games/figgos/components/TabBarIcon.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
87
games/figgos/components/ThemedView.tsx
Normal file
87
games/figgos/components/ThemedView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
59
games/figgos/components/subscription/BillingToggle.tsx
Normal file
59
games/figgos/components/subscription/BillingToggle.tsx
Normal 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;
|
||||
39
games/figgos/components/subscription/CostCard.tsx
Normal file
39
games/figgos/components/subscription/CostCard.tsx
Normal 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;
|
||||
61
games/figgos/components/subscription/PackageCard.tsx
Normal file
61
games/figgos/components/subscription/PackageCard.tsx
Normal 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;
|
||||
98
games/figgos/components/subscription/SubscriptionButton.tsx
Normal file
98
games/figgos/components/subscription/SubscriptionButton.tsx
Normal 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;
|
||||
125
games/figgos/components/subscription/SubscriptionCard.tsx
Normal file
125
games/figgos/components/subscription/SubscriptionCard.tsx
Normal 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;
|
||||
254
games/figgos/components/subscription/SubscriptionPage.tsx
Normal file
254
games/figgos/components/subscription/SubscriptionPage.tsx
Normal 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;
|
||||
85
games/figgos/components/subscription/UsageCard.tsx
Normal file
85
games/figgos/components/subscription/UsageCard.tsx
Normal 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;
|
||||
19
games/figgos/components/subscription/appCosts.json
Normal file
19
games/figgos/components/subscription/appCosts.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
138
games/figgos/components/subscription/subscriptionData.json
Normal file
138
games/figgos/components/subscription/subscriptionData.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
18
games/figgos/components/subscription/usageData.json
Normal file
18
games/figgos/components/subscription/usageData.json
Normal 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
21
games/figgos/eas.json
Normal 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
23
games/figgos/global.css
Normal 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;
|
||||
}
|
||||
9
games/figgos/metro.config.js
Normal file
9
games/figgos/metro.config.js
Normal 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
3
games/figgos/nativewind-env.d.ts
vendored
Normal 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
63
games/figgos/package.json
Normal 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
|
||||
}
|
||||
10
games/figgos/prettier.config.js
Normal file
10
games/figgos/prettier.config.js
Normal 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'],
|
||||
};
|
||||
1
games/figgos/supabase/.temp/cli-latest
Normal file
1
games/figgos/supabase/.temp/cli-latest
Normal file
|
|
@ -0,0 +1 @@
|
|||
v2.22.6
|
||||
6
games/figgos/supabase/functions/_shared/cors.ts
Normal file
6
games/figgos/supabase/functions/_shared/cors.ts
Normal 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',
|
||||
};
|
||||
412
games/figgos/supabase/functions/figure-generator/index.ts
Normal file
412
games/figgos/supabase/functions/figure-generator/index.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
412
games/figgos/supabase/functions/wtfigure-generator/index.ts
Normal file
412
games/figgos/supabase/functions/wtfigure-generator/index.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
10
games/figgos/tailwind.config.js
Normal file
10
games/figgos/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
12
games/figgos/tsconfig.json
Normal file
12
games/figgos/tsconfig.json
Normal 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"]
|
||||
}
|
||||
89
games/figgos/utils/AuthContext.tsx
Normal file
89
games/figgos/utils/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
105
games/figgos/utils/ErrorHandler.tsx
Normal file
105
games/figgos/utils/ErrorHandler.tsx
Normal 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;
|
||||
142
games/figgos/utils/ThemeContext.tsx
Normal file
142
games/figgos/utils/ThemeContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
4
games/figgos/utils/config.ts
Normal file
4
games/figgos/utils/config.ts
Normal 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';
|
||||
444
games/figgos/utils/figureService.ts
Normal file
444
games/figgos/utils/figureService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
42
games/figgos/utils/supabase.ts
Normal file
42
games/figgos/utils/supabase.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
150
games/figgos/utils/themes.ts
Normal file
150
games/figgos/utils/themes.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue