first commit

This commit is contained in:
2026-05-09 16:00:19 +08:00
commit f31409bdba
12 changed files with 5461 additions and 0 deletions

603
src/App.tsx Normal file
View File

@@ -0,0 +1,603 @@
import React, { useState, useEffect, useRef } from 'react';
import { Upload, Image as ImageIcon, Wand2, Loader2, AlertCircle, X, Download, Lock, User, LogOut, Settings } from 'lucide-react';
import { GoogleGenAI } from '@google/genai';
declare global {
interface Window {
aistudio?: {
hasSelectedApiKey: () => Promise<boolean>;
openSelectKey: () => Promise<void>;
};
}
}
function useApiKey() {
const [hasKey, setHasKey] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
checkKey();
}, []);
const checkKey = async () => {
try {
if (window.aistudio?.hasSelectedApiKey) {
const result = await window.aistudio.hasSelectedApiKey();
setHasKey(result);
} else {
setHasKey(true);
}
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
const selectKey = async () => {
try {
if (window.aistudio?.openSelectKey) {
await window.aistudio.openSelectKey();
setHasKey(true);
}
} catch (e) {
console.error(e);
if (e instanceof Error && e.message.includes("Requested entity was not found.")) {
setHasKey(false);
}
}
};
return { hasKey, isLoading, selectKey };
}
function useAuth() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loggedIn = localStorage.getItem('isLoggedIn') === 'true';
setIsLoggedIn(loggedIn);
setIsLoading(false);
}, []);
const login = (username: string, password: string) => {
const envPassword = process.env.APP_PASSWORD || '123456';
const storedPassword = localStorage.getItem('appPassword') || envPassword;
if (username === 'admin' && password === storedPassword) {
localStorage.setItem('isLoggedIn', 'true');
setIsLoggedIn(true);
return true;
}
return false;
};
const logout = () => {
localStorage.removeItem('isLoggedIn');
setIsLoggedIn(false);
};
const changePassword = (newPassword: string) => {
localStorage.setItem('appPassword', newPassword);
};
return { isLoggedIn, isLoading, login, logout, changePassword };
}
function Login({ onLogin }: { onLogin: (u: string, p: string) => boolean }) {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (onLogin(username, password)) {
setError('');
} else {
setError('Invalid username or password');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-950 p-4">
<div className="max-w-md w-full bg-zinc-900 border border-zinc-800 rounded-2xl p-8 space-y-8 shadow-2xl">
<div className="text-center space-y-2">
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto">
<Lock className="w-8 h-8 text-emerald-500" />
</div>
<h1 className="text-2xl font-bold text-white">Private Access</h1>
<p className="text-zinc-400 text-sm">Please sign in to continue</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider ml-1">Username</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 pl-10 pr-4 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all"
placeholder="admin"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider ml-1">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 pl-10 pr-4 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all"
placeholder="••••••"
/>
</div>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-400/10 p-3 rounded-xl border border-red-400/20">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<button
type="submit"
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98]"
>
Sign In
</button>
</form>
</div>
</div>
);
}
function ChangePasswordModal({ onClose, onChange }: { onClose: () => void, onChange: (p: string) => void }) {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newPassword.length < 6) {
setError('Password must be at least 6 characters');
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
onChange(newPassword);
onClose();
};
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="max-w-md w-full bg-zinc-900 border border-zinc-800 rounded-2xl p-8 space-y-6 shadow-2xl">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-white">Change Password</h2>
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider ml-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 px-4 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all"
placeholder="At least 6 characters"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider ml-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 px-4 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all"
placeholder="Confirm new password"
/>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-400/10 p-3 rounded-xl border border-red-400/20">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<button
type="submit"
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-bold transition-all"
>
Update Password
</button>
</form>
</div>
</div>
);
}
export default function App() {
const { isLoggedIn, isLoading: isAuthLoading, login, logout, changePassword } = useAuth();
const { hasKey, isLoading: isKeyLoading, selectKey } = useApiKey();
const [showChangePassword, setShowChangePassword] = useState(false);
if (isAuthLoading || isKeyLoading) {
return <div className="min-h-screen flex items-center justify-center bg-zinc-950 text-zinc-200"><Loader2 className="animate-spin" /></div>;
}
if (!isLoggedIn) {
return <Login onLogin={login} />;
}
if (!hasKey) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-zinc-950 text-zinc-200 p-4">
<div className="max-w-md w-full bg-zinc-900 border border-zinc-800 rounded-2xl p-8 text-center space-y-6">
<div className="w-16 h-16 bg-zinc-800 rounded-full flex items-center justify-center mx-auto">
<Wand2 className="w-8 h-8 text-emerald-400" />
</div>
<h1 className="text-2xl font-semibold text-white">API Key Required</h1>
<p className="text-zinc-400">
To use the Gemini 3.1 Flash Image model, you need to select a paid Google Cloud API key.
</p>
<button
onClick={selectKey}
className="w-full py-3 px-4 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-medium transition-colors"
>
Select API Key
</button>
<p className="text-xs text-zinc-500">
<a href="https://ai.google.dev/gemini-api/docs/billing" target="_blank" rel="noreferrer" className="underline hover:text-zinc-300">
Billing documentation
</a>
</p>
</div>
</div>
);
}
return (
<>
<ImageEditor
onSelectKey={selectKey}
onLogout={logout}
onChangePassword={() => setShowChangePassword(true)}
/>
{showChangePassword && (
<ChangePasswordModal
onClose={() => setShowChangePassword(false)}
onChange={changePassword}
/>
)}
</>
);
}
function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey: () => Promise<void>, onLogout: () => void, onChangePassword: () => void }) {
const [image, setImage] = useState<string | null>(null);
const [mimeType, setMimeType] = useState<string>('');
const [prompt, setPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [resultImage, setResultImage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [imageSize, setImageSize] = useState<'1K' | '2K' | '4K'>('1K');
const [aspectRatio, setAspectRatio] = useState<'1:1' | '4:3' | '3:4' | '16:9' | '9:16'>('1:1');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setMimeType(file.type);
const reader = new FileReader();
reader.onload = (event) => {
setImage(event.target?.result as string);
};
reader.readAsDataURL(file);
};
const handleClearImage = (e: React.MouseEvent) => {
e.stopPropagation();
setImage(null);
setMimeType('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleDownload = () => {
if (!resultImage) return;
const a = document.createElement('a');
a.href = resultImage;
a.download = `generated-image-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const handleUseAsSource = () => {
if (!resultImage) return;
setImage(resultImage);
// Try to extract mime type from data URL
const match = resultImage.match(/^data:(image\/[a-z]+);base64,/);
if (match) {
setMimeType(match[1]);
}
setResultImage(null);
};
const handleGenerate = async () => {
if (!prompt) return;
setIsGenerating(true);
setError(null);
try {
const apiKey = process.env.API_KEY || process.env.GEMINI_API_KEY;
const ai = new GoogleGenAI({ apiKey });
const parts: any[] = [];
if (image) {
const base64Data = image.split(',')[1];
parts.push({
inlineData: {
data: base64Data,
mimeType: mimeType,
},
});
}
parts.push({ text: prompt });
const response = await ai.models.generateContent({
model: 'gemini-3.1-flash-image-preview',
contents: { parts },
config: {
imageConfig: {
imageSize,
aspectRatio,
},
},
});
let foundImage = false;
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
const base64EncodeString = part.inlineData.data;
setResultImage(`data:${part.inlineData.mimeType || 'image/png'};base64,${base64EncodeString}`);
foundImage = true;
break;
}
}
if (!foundImage) {
setError("No image was returned by the model. It might have returned text instead.");
}
} catch (err: any) {
console.error(err);
setError(err.message || "An error occurred during generation.");
if (err.message?.includes("Requested entity was not found.")) {
setError("API Key error. Please refresh the page and select your API key again.");
}
} finally {
setIsGenerating(false);
}
};
return (
<div className="min-h-screen bg-zinc-950 text-zinc-200 p-6 font-sans">
<div className="max-w-6xl mx-auto space-y-8">
<header className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white tracking-tight">AI Image Studio</h1>
<p className="text-zinc-400 mt-1">Powered by Gemini 3.1 Flash Image</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={onChangePassword}
className="p-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white rounded-lg transition-colors border border-zinc-700"
title="Change Password"
>
<Settings className="w-5 h-5" />
</button>
<button
onClick={onLogout}
className="p-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white rounded-lg transition-colors border border-zinc-700"
title="Logout"
>
<LogOut className="w-5 h-5" />
</button>
<button
onClick={onSelectKey}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded-lg text-sm font-medium transition-colors border border-zinc-700"
>
Restart / Change API Key
</button>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 space-y-4">
<h2 className="text-lg font-medium text-white flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-zinc-400" />
Source Image (Optional)
</h2>
<div
className={`border-2 border-dashed rounded-xl overflow-hidden transition-colors ${image ? 'border-zinc-700 bg-zinc-950' : 'border-zinc-800 hover:border-zinc-700 bg-zinc-900/50'} relative aspect-video flex items-center justify-center cursor-pointer group`}
onClick={() => !image && fileInputRef.current?.click()}
>
{image ? (
<>
<img src={image} alt="Source" className="w-full h-full object-contain" referrerPolicy="no-referrer" />
<button
onClick={handleClearImage}
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors opacity-0 group-hover:opacity-100"
>
<X className="w-4 h-4" />
</button>
</>
) : (
<div className="text-center p-6">
<Upload className="w-8 h-8 text-zinc-500 mx-auto mb-3" />
<p className="text-sm text-zinc-400">Click to upload an image to edit</p>
<p className="text-xs text-zinc-500 mt-1">Leave empty to generate from scratch</p>
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/*"
className="hidden"
/>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 space-y-4">
<h2 className="text-lg font-medium text-white flex items-center gap-2">
<Wand2 className="w-5 h-5 text-zinc-400" />
Settings
</h2>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">Resolution</label>
<select
value={imageSize}
onChange={(e) => setImageSize(e.target.value as any)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
>
<option value="1K">1K (Standard)</option>
<option value="2K">2K (High)</option>
<option value="4K">4K (Ultra)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">Aspect Ratio</label>
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value as any)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg p-2 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-emerald-500/50"
>
<option value="1:1">1:1 Square</option>
<option value="4:3">4:3 Classic</option>
<option value="3:4">3:4 Portrait</option>
<option value="16:9">16:9 Wide</option>
<option value="9:16">9:16 Tall</option>
</select>
</div>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 space-y-4">
<h2 className="text-lg font-medium text-white flex items-center gap-2">
<Wand2 className="w-5 h-5 text-zinc-400" />
Instructions
</h2>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full h-40 bg-zinc-950 border border-zinc-800 rounded-xl p-4 text-zinc-200 placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 resize-none"
placeholder="Describe how you want to create or edit the image..."
/>
<button
onClick={handleGenerate}
disabled={!prompt || isGenerating}
className="w-full py-3 px-4 bg-white text-black hover:bg-zinc-200 disabled:bg-zinc-800 disabled:text-zinc-500 rounded-xl font-medium transition-colors flex items-center justify-center gap-2"
>
{isGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Generating...
</>
) : (
<>
<Wand2 className="w-5 h-5" />
{image ? 'Edit Image' : 'Create Image'}
</>
)}
</button>
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex flex-col gap-3 text-red-400">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
<p className="text-sm">{error}</p>
</div>
{(error.includes("API Key") || error.includes("permission") || error.includes("403")) && (
<button
onClick={onSelectKey}
className="self-start px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-300 rounded-lg text-sm font-medium transition-colors"
>
Reselect API Key
</button>
)}
</div>
)}
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-white flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-zinc-400" />
Result
</h2>
{resultImage && !isGenerating && (
<div className="flex items-center gap-2">
<button
onClick={handleUseAsSource}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 border border-zinc-700"
title="Use this result as the new source image for further editing"
>
<Wand2 className="w-4 h-4" />
Use as Source
</button>
<button
onClick={handleDownload}
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<Download className="w-4 h-4" />
Download
</button>
</div>
)}
</div>
<div className="flex-1 border border-zinc-800 rounded-xl bg-zinc-950 overflow-hidden relative flex items-center justify-center min-h-[400px]">
{isGenerating ? (
<div className="text-center space-y-4">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin mx-auto" />
<p className="text-sm text-zinc-500">Applying edits...</p>
</div>
) : resultImage ? (
<img src={resultImage} alt="Result" className="w-full h-full object-contain" referrerPolicy="no-referrer" />
) : (
<p className="text-sm text-zinc-600">Generated image will appear here</p>
)}
</div>
</div>
</div>
</div>
</div>
);
}