385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TouchableOpacity,
|
|
ScrollView,
|
|
FlatList,
|
|
TextInput,
|
|
Modal,
|
|
} from 'react-native';
|
|
import Slider from '@react-native-community/slider';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { Trash2, Plus, Check } from 'lucide-react-native';
|
|
import { Recipe, ColorAdjustments, FrameId } from '../types';
|
|
import { FRAMES } from '../utils/frameUtils';
|
|
|
|
interface AdjustmentPanelProps {
|
|
activeTab: 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
|
|
recipes: Recipe[];
|
|
currentRecipeId: string;
|
|
adjustments: ColorAdjustments;
|
|
selectedFrame: FrameId;
|
|
useGeotag: boolean;
|
|
onSelectRecipe: (recipe: Recipe) => void;
|
|
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
|
|
onUpdateFrame: (frameId: FrameId) => void;
|
|
onToggleGeotag: (enabled: boolean) => void;
|
|
onSaveRecipe: (name: string) => void;
|
|
onDeleteRecipe: (id: string) => void;
|
|
}
|
|
|
|
export default function AdjustmentPanel({
|
|
activeTab,
|
|
recipes,
|
|
currentRecipeId,
|
|
adjustments,
|
|
selectedFrame,
|
|
useGeotag,
|
|
onSelectRecipe,
|
|
onUpdateAdjustments,
|
|
onUpdateFrame,
|
|
onToggleGeotag,
|
|
onSaveRecipe,
|
|
onDeleteRecipe,
|
|
}: AdjustmentPanelProps) {
|
|
const [modalVisible, setModalVisible] = useState(false);
|
|
const [newRecipeName, setNewRecipeName] = useState('');
|
|
|
|
const triggerHaptic = () => {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
};
|
|
|
|
const renderRecipeItem = React.useCallback(
|
|
({ item }: { item: Recipe }) => (
|
|
<RecipeItem
|
|
item={item}
|
|
isActive={item.id === currentRecipeId}
|
|
onSelect={onSelectRecipe}
|
|
onDelete={onDeleteRecipe}
|
|
triggerHaptic={triggerHaptic}
|
|
/>
|
|
),
|
|
[currentRecipeId, onSelectRecipe, onDeleteRecipe]
|
|
);
|
|
|
|
const handleSliderValueChange = () => {
|
|
// optional: light haptic while sliding, or only on completion.
|
|
// Usually Haptic on sliding is too heavy, so we don't trigger it on every frame,
|
|
// or we only trigger on sliding complete.
|
|
};
|
|
|
|
const handleSavePress = () => {
|
|
if (newRecipeName.trim()) {
|
|
onSaveRecipe(newRecipeName.trim());
|
|
setNewRecipeName('');
|
|
setModalVisible(false);
|
|
triggerHaptic();
|
|
}
|
|
};
|
|
|
|
const renderSlider = (
|
|
label: string,
|
|
value: number,
|
|
min: number,
|
|
max: number,
|
|
step: number,
|
|
onValueChange: (val: number) => void,
|
|
displayValueMapper?: (val: number) => string
|
|
) => {
|
|
const displayVal = displayValueMapper ? displayValueMapper(value) : value.toString();
|
|
return (
|
|
<View className="mb-4 px-4">
|
|
<View className="flex-row justify-between items-center mb-1">
|
|
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider">{label.toUpperCase()}</Text>
|
|
<Text className="text-amber-500 font-mono text-xs font-semibold">{displayVal}</Text>
|
|
</View>
|
|
<Slider
|
|
value={value}
|
|
minimumValue={min}
|
|
maximumValue={max}
|
|
step={step}
|
|
onValueChange={onValueChange}
|
|
onSlidingComplete={triggerHaptic}
|
|
minimumTrackTintColor="#f59e0b"
|
|
maximumTrackTintColor="#27272a"
|
|
thumbTintColor="#f59e0b"
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
switch (activeTab) {
|
|
case 'recipes':
|
|
return (
|
|
<View className="flex-1 bg-carbon">
|
|
<View className="flex-row justify-between items-center px-4 py-2 bg-titan border-b border-zinc-800">
|
|
<Text className="text-zinc-400 font-mono text-xs">SELECT OR CREATE PRESETS</Text>
|
|
<TouchableOpacity
|
|
onPress={() => setModalVisible(true)}
|
|
className="flex-row items-center bg-amber-500 px-3 py-1 rounded space-x-1"
|
|
activeOpacity={0.7}
|
|
>
|
|
<Plus size={14} color="#000000" />
|
|
<Text className="text-black font-mono text-xs font-bold">SAVE CURRENT</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<FlatList
|
|
data={recipes}
|
|
keyExtractor={(item) => item.id}
|
|
numColumns={2}
|
|
className="p-2"
|
|
columnWrapperStyle={{ justifyContent: 'space-between' }}
|
|
renderItem={renderRecipeItem}
|
|
/>
|
|
|
|
{/* Save Recipe Modal */}
|
|
<Modal
|
|
transparent
|
|
animationType="slide"
|
|
visible={modalVisible}
|
|
onRequestClose={() => setModalVisible(false)}
|
|
>
|
|
<View className="flex-1 justify-end bg-black/60">
|
|
<View className="bg-titan border-t border-zinc-800 rounded-t-3xl p-6 pb-8">
|
|
<Text className="text-white font-mono text-sm font-bold tracking-wider mb-4">
|
|
SAVE NEW RECIPE
|
|
</Text>
|
|
<TextInput
|
|
value={newRecipeName}
|
|
onChangeText={setNewRecipeName}
|
|
placeholder="Enter recipe name (e.g. Fuji Retro 🏮)"
|
|
placeholderTextColor="#71717a"
|
|
className="bg-carbon border border-zinc-800 rounded-lg p-3 text-white font-mono text-sm mb-4"
|
|
autoFocus
|
|
/>
|
|
<View className="flex-row space-x-3">
|
|
<TouchableOpacity
|
|
onPress={() => setModalVisible(false)}
|
|
className="flex-1 bg-zinc-800 py-3 rounded-lg items-center"
|
|
>
|
|
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={handleSavePress}
|
|
className="flex-1 bg-amber-500 py-3 rounded-lg items-center"
|
|
>
|
|
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
|
|
case 'iq':
|
|
return (
|
|
<ScrollView className="flex-1 bg-carbon py-3">
|
|
{renderSlider('Exposure', adjustments.exposure, -10, 10, 1, (val) =>
|
|
onUpdateAdjustments({ exposure: val })
|
|
)}
|
|
{renderSlider('Contrast', adjustments.contrast, -10, 10, 1, (val) =>
|
|
onUpdateAdjustments({ contrast: val })
|
|
)}
|
|
{renderSlider('Saturation', adjustments.saturation, -10, 10, 1, (val) =>
|
|
onUpdateAdjustments({ saturation: val })
|
|
)}
|
|
</ScrollView>
|
|
);
|
|
|
|
case 'wb':
|
|
return (
|
|
<ScrollView className="flex-1 bg-carbon py-3">
|
|
{renderSlider(
|
|
'Color Temp (Kelvin)',
|
|
adjustments.temperature,
|
|
2500,
|
|
10000,
|
|
100,
|
|
(val) => onUpdateAdjustments({ temperature: val }),
|
|
(val) => `${val}K`
|
|
)}
|
|
{renderSlider('Tint (Green-Magenta)', adjustments.tint, -10, 10, 1, (val) =>
|
|
onUpdateAdjustments({ tint: val })
|
|
)}
|
|
|
|
<View className="px-4 mb-4">
|
|
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider mb-2">
|
|
COLOR CHROME EFFECT
|
|
</Text>
|
|
<View className="flex-row bg-titan p-0.5 rounded-lg border border-zinc-800">
|
|
{(['none', 'weak', 'strong'] as const).map((chrome) => {
|
|
const isActive = adjustments.colorChrome === chrome;
|
|
return (
|
|
<TouchableOpacity
|
|
key={chrome}
|
|
onPress={() => {
|
|
onUpdateAdjustments({ colorChrome: chrome });
|
|
triggerHaptic();
|
|
}}
|
|
className={`flex-1 py-1.5 rounded-md items-center ${
|
|
isActive ? 'bg-amber-500' : 'bg-transparent'
|
|
}`}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text
|
|
className={`font-mono text-xs font-bold ${
|
|
isActive ? 'text-black' : 'text-zinc-400'
|
|
}`}
|
|
>
|
|
{chrome.toUpperCase()}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
);
|
|
|
|
case 'filters':
|
|
return (
|
|
<ScrollView className="flex-1 bg-carbon py-3">
|
|
{renderSlider('Highlight Contrast', adjustments.highlight, -10, 10, 1, (val) =>
|
|
onUpdateAdjustments({ highlight: val })
|
|
)}
|
|
{renderSlider('Shadow Contrast', adjustments.shadow, -10, 10, 1, (val) =>
|
|
onUpdateAdjustments({ shadow: val })
|
|
)}
|
|
{renderSlider('Denoise (Blur)', adjustments.denoise, 0, 10, 1, (val) =>
|
|
onUpdateAdjustments({ denoise: val })
|
|
)}
|
|
{renderSlider('Clarity (Sharp/Soft)', adjustments.clarity, -10, 10, 1, (val) =>
|
|
onUpdateAdjustments({ clarity: val })
|
|
)}
|
|
{renderSlider('Monochrome Grain', adjustments.grain, 0, 10, 1, (val) =>
|
|
onUpdateAdjustments({ grain: val })
|
|
)}
|
|
</ScrollView>
|
|
);
|
|
|
|
case 'frame':
|
|
return (
|
|
<ScrollView className="flex-1 bg-carbon py-4">
|
|
<View className="px-4 mb-5">
|
|
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider mb-3">
|
|
SELECT PNG FRAME OVERLAY
|
|
</Text>
|
|
<View className="flex-row flex-wrap">
|
|
{FRAMES.map((f: { id: FrameId; name: string }) => {
|
|
const isActive = selectedFrame === f.id;
|
|
return (
|
|
<TouchableOpacity
|
|
key={f.id}
|
|
onPress={() => {
|
|
onUpdateFrame(f.id);
|
|
triggerHaptic();
|
|
}}
|
|
className={`px-3 py-2 m-1 rounded border font-mono ${
|
|
isActive
|
|
? 'bg-amber-500/10 border-amber-500 text-amber-500'
|
|
: 'bg-titan border-zinc-800 text-zinc-400'
|
|
}`}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text
|
|
className={`font-mono text-xs font-bold ${
|
|
isActive ? 'text-amber-500' : 'text-zinc-400'
|
|
}`}
|
|
>
|
|
{f.name}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
|
|
<View className="px-4 flex-row items-center justify-between border-t border-zinc-800/50 pt-4">
|
|
<View>
|
|
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-wider">
|
|
GPS GEOTAG WATERMARK
|
|
</Text>
|
|
<Text className="text-zinc-500 font-mono text-[10px] mt-0.5">
|
|
Stamps coordinates & place names onto photo
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
onToggleGeotag(!useGeotag);
|
|
triggerHaptic();
|
|
}}
|
|
className={`w-12 h-6 rounded-full p-0.5 justify-center ${
|
|
useGeotag ? 'bg-amber-500 items-end' : 'bg-zinc-800 items-start'
|
|
}`}
|
|
activeOpacity={0.8}
|
|
>
|
|
<View className="w-5 h-5 rounded-full bg-black border border-zinc-950 flex justify-center items-center">
|
|
{useGeotag && <Check size={10} color="#f59e0b" />}
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</ScrollView>
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface RecipeItemProps {
|
|
item: Recipe;
|
|
isActive: boolean;
|
|
onSelect: (recipe: Recipe) => void;
|
|
onDelete: (id: string) => void;
|
|
triggerHaptic: () => void;
|
|
}
|
|
|
|
const RecipeItem = React.memo(({ item, isActive, onSelect, onDelete, triggerHaptic }: RecipeItemProps) => {
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
onSelect(item);
|
|
triggerHaptic();
|
|
}}
|
|
className={`flex-1 m-1.5 p-3 rounded-lg border ${
|
|
isActive
|
|
? 'bg-amber-500/10 border-amber-500'
|
|
: 'bg-titan border-zinc-800/80'
|
|
}`}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View className="flex-row justify-between items-start">
|
|
<View className="flex-1 mr-1">
|
|
<Text
|
|
numberOfLines={1}
|
|
className={`font-mono text-xs font-bold ${
|
|
isActive ? 'text-amber-500' : 'text-zinc-200'
|
|
}`}
|
|
>
|
|
{item.name}
|
|
</Text>
|
|
<Text className="text-[10px] text-zinc-500 font-mono mt-0.5">
|
|
{item.baseFilter.toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
{item.isCustom && (
|
|
<TouchableOpacity
|
|
onPress={(e) => {
|
|
e.stopPropagation();
|
|
onDelete(item.id);
|
|
triggerHaptic();
|
|
}}
|
|
className="p-1"
|
|
>
|
|
<Trash2 size={12} color="#ef4444" />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
});
|
|
|