Update API key usage and credit checking logic for comic generation
This commit is contained in:
@@ -140,7 +140,7 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const client = new Together({
|
const client = new Together({
|
||||||
apiKey: process.env.TOGETHER_API_KEY_DEFAULT,
|
apiKey: process.env.TOGETHER_API_KEY,
|
||||||
});
|
});
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { freeTierRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { stories } from "@/lib/schema";
|
||||||
|
import { gte } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication required" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasApiKey } = await request.json();
|
||||||
|
|
||||||
|
// Check if user has API key (unlimited)
|
||||||
|
if (hasApiKey) {
|
||||||
|
return NextResponse.json({
|
||||||
|
hasApiKey: true,
|
||||||
|
creditsRemaining: "unlimited",
|
||||||
|
resetTime: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For free tier, check rate limit status
|
||||||
|
try {
|
||||||
|
// Try to check remaining without consuming
|
||||||
|
const limitResult = await freeTierRateLimit.getRemaining(userId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
hasApiKey: false,
|
||||||
|
creditsRemaining: limitResult.remaining,
|
||||||
|
resetTime: limitResult.reset,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Rate limit check error:", error);
|
||||||
|
// Fallback to database check
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
|
const recentStories = await db
|
||||||
|
.select()
|
||||||
|
.from(stories)
|
||||||
|
.where(gte(stories.createdAt, sevenDaysAgo))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasUsedCredit = recentStories.length > 0;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
hasApiKey: false,
|
||||||
|
creditsRemaining: hasUsedCredit ? 0 : 1,
|
||||||
|
resetTime: hasUsedCredit ? sevenDaysAgo.getTime() + 7 * 24 * 60 * 60 * 1000 : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in check-credits API:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Internal server error: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (isUsingFreeTier) {
|
if (isUsingFreeTier) {
|
||||||
// Use default API key for free tier
|
// Use default API key for free tier
|
||||||
finalApiKey = process.env.TOGETHER_API_KEY_DEFAULT;
|
finalApiKey = process.env.TOGETHER_API_KEY
|
||||||
if (!finalApiKey) {
|
if (!finalApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -167,16 +167,55 @@ export function StoryEditorClient() {
|
|||||||
}, [pages.length, apiKey]);
|
}, [pages.length, apiKey]);
|
||||||
|
|
||||||
|
|
||||||
const handleAddPage = () => {
|
const handleAddPage = async () => {
|
||||||
if (!isLoaded || !isSignedIn) {
|
if (!isLoaded || !isSignedIn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey && pages.length >= 1) {
|
// Check credits
|
||||||
setShowApiModal(true);
|
try {
|
||||||
return;
|
const hasApiKey = !!apiKey;
|
||||||
|
const response = await fetch('/api/check-credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ hasApiKey }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to check credits",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasApiKey || data.creditsRemaining === "unlimited") {
|
||||||
|
// Has API key, unlimited
|
||||||
|
setShowGenerateModal(true);
|
||||||
|
} else if (data.creditsRemaining > 0) {
|
||||||
|
// Has credits
|
||||||
|
setShowGenerateModal(true);
|
||||||
|
} else {
|
||||||
|
// No credits left, show API modal
|
||||||
|
setShowApiModal(true);
|
||||||
|
toast({
|
||||||
|
title: "No credits remaining",
|
||||||
|
description: "You get 1 credit weekly. Add an API key for unlimited generation.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking credits:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to check credits",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setShowGenerateModal(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRedrawPage = () => {
|
const handleRedrawPage = () => {
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|||||||
: "Add your API key to continue"}
|
: "Add your API key to continue"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="text-center text-muted-foreground">
|
<DialogDescription className="text-center text-muted-foreground">
|
||||||
{existingKey
|
{existingKey
|
||||||
? "Update your Together API key or add a new one. You can also delete your existing key."
|
? "Update your Together API key or add a new one. You can also delete your existing key."
|
||||||
: "Your first page was free! Add your Together API key to generate more pages."}
|
: "You've used your weekly credit! Add your Together API key for unlimited generation."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { COMIC_STYLES } from "@/lib/constants";
|
|||||||
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
||||||
import { useApiKey } from "@/hooks/use-api-key";
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
import { isContentPolicyViolation } from "@/lib/utils";
|
import { isContentPolicyViolation } from "@/lib/utils";
|
||||||
|
import { ApiKeyModal } from "@/components/api-key-modal";
|
||||||
|
|
||||||
interface ComicCreationFormProps {
|
interface ComicCreationFormProps {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -23,11 +24,14 @@ interface ComicCreationFormProps {
|
|||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STYLE = 'noir';
|
||||||
|
const STYLE_STORAGE_KEY = 'comic-style-preference';
|
||||||
|
|
||||||
export function ComicCreationForm({
|
export function ComicCreationForm({
|
||||||
prompt,
|
prompt,
|
||||||
setPrompt,
|
setPrompt,
|
||||||
style,
|
style: initialStyle,
|
||||||
setStyle,
|
setStyle: setParentStyle,
|
||||||
characterFiles,
|
characterFiles,
|
||||||
setCharacterFiles,
|
setCharacterFiles,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -39,11 +43,22 @@ export function ComicCreationForm({
|
|||||||
const { uploadToS3 } = useS3Upload();
|
const { uploadToS3 } = useS3Upload();
|
||||||
const { isSignedIn, isLoaded } = useAuth();
|
const { isSignedIn, isLoaded } = useAuth();
|
||||||
const { openSignIn } = useClerk();
|
const { openSignIn } = useClerk();
|
||||||
const [apiKey] = useApiKey();
|
const [apiKey, setApiKey] = useApiKey();
|
||||||
const hasApiKey = !!apiKey;
|
const hasApiKey = !!apiKey;
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
const [showPreview, setShowPreview] = useState<number | null>(null);
|
const [showPreview, setShowPreview] = useState<number | null>(null);
|
||||||
const [showStyleDropdown, setShowStyleDropdown] = useState(false);
|
const [showStyleDropdown, setShowStyleDropdown] = useState(false);
|
||||||
|
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
|
||||||
|
const [showApiModal, setShowApiModal] = useState(false);
|
||||||
|
|
||||||
|
// Initialize style with saved preference or default
|
||||||
|
const [style, setStyle] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem(STYLE_STORAGE_KEY);
|
||||||
|
return saved || initialStyle || DEFAULT_STYLE;
|
||||||
|
}
|
||||||
|
return initialStyle || DEFAULT_STYLE;
|
||||||
|
});
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -78,6 +93,38 @@ export function ComicCreationForm({
|
|||||||
}
|
}
|
||||||
}, []); // Run only on mount
|
}, []); // Run only on mount
|
||||||
|
|
||||||
|
// Save style to localStorage and sync with parent
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STYLE_STORAGE_KEY, style);
|
||||||
|
setParentStyle(style);
|
||||||
|
}, [style, setParentStyle]);
|
||||||
|
|
||||||
|
// Fetch credits on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSignedIn && !hasApiKey) {
|
||||||
|
const fetchCredits = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/check-credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ hasApiKey: false }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
setCreditsRemaining(data.creditsRemaining);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching credits:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCredits();
|
||||||
|
} else if (hasApiKey) {
|
||||||
|
setCreditsRemaining(null); // Unlimited
|
||||||
|
}
|
||||||
|
}, [isSignedIn, hasApiKey]);
|
||||||
|
|
||||||
// Keyboard shortcut for form submission
|
// Keyboard shortcut for form submission
|
||||||
useKeyboardShortcut(() => {
|
useKeyboardShortcut(() => {
|
||||||
if (!isLoading && prompt.trim()) {
|
if (!isLoading && prompt.trim()) {
|
||||||
@@ -151,15 +198,33 @@ export function ComicCreationForm({
|
|||||||
setLoadingStep(0);
|
setLoadingStep(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!apiKey) {
|
// Check credits
|
||||||
toast({
|
const hasApiKey = !!apiKey;
|
||||||
title: "API key required",
|
if (!hasApiKey) {
|
||||||
description: "Please add your API key to generate comics.",
|
const creditsResponse = await fetch('/api/check-credits', {
|
||||||
variant: "destructive",
|
method: 'POST',
|
||||||
duration: 3000,
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ hasApiKey }),
|
||||||
});
|
});
|
||||||
setIsLoading(false);
|
const creditsData = await creditsResponse.json();
|
||||||
return;
|
|
||||||
|
if (!creditsResponse.ok) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to check credits",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creditsData.creditsRemaining === 0) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const characterUploads = await Promise.all(
|
const characterUploads = await Promise.all(
|
||||||
@@ -174,7 +239,7 @@ export function ComicCreationForm({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt,
|
prompt,
|
||||||
apiKey,
|
...(apiKey && { apiKey }),
|
||||||
style,
|
style,
|
||||||
characterImages: characterUploads,
|
characterImages: characterUploads,
|
||||||
}),
|
}),
|
||||||
@@ -214,6 +279,11 @@ export function ComicCreationForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApiKeySubmit = (key: string) => {
|
||||||
|
setApiKey(key);
|
||||||
|
setShowApiModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
const isEnter = e.key === "Enter" || e.key === "\n" || e.keyCode === 13;
|
const isEnter = e.key === "Enter" || e.key === "\n" || e.keyCode === 13;
|
||||||
const isModifierPressed = e.shiftKey || e.ctrlKey || e.metaKey; // metaKey for Cmd on Mac
|
const isModifierPressed = e.shiftKey || e.ctrlKey || e.metaKey; // metaKey for Cmd on Mac
|
||||||
@@ -418,7 +488,7 @@ export function ComicCreationForm({
|
|||||||
{hasApiKey ? (
|
{hasApiKey ? (
|
||||||
<>Using your API key (~$0.01 per comic)</>
|
<>Using your API key (~$0.01 per comic)</>
|
||||||
) : (
|
) : (
|
||||||
<>1 credit weekly</>
|
<>{creditsRemaining !== null ? `${creditsRemaining} credit${creditsRemaining === 1 ? '' : 's'} remaining` : 'Checking credits...'}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,6 +501,12 @@ export function ComicCreationForm({
|
|||||||
</SignInButton>
|
</SignInButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ApiKeyModal
|
||||||
|
isOpen={showApiModal}
|
||||||
|
onClose={() => setShowApiModal(false)}
|
||||||
|
onSubmit={handleApiKeySubmit}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user