Files
RecipesCam/src/components/AdjustmentPanel.tsx
T
2026-07-17 09:33:24 +07:00

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>
);
});