Compare commits
10 Commits
dd67b14a9c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fbdf84fca7 | |||
| 624a729b24 | |||
| e18f67a7d0 | |||
| 3270f38d94 | |||
| 62f8a0ca35 | |||
| 200a4e2a29 | |||
| 9cf3e31af2 | |||
| a05fdea297 | |||
| a56a21bc87 | |||
| 4dc2266a7e |
@@ -27,3 +27,7 @@ next-env.d.ts
|
|||||||
/.clerk/
|
/.clerk/
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
IDEAS.md
|
||||||
|
|
||||||
|
.claude/launch.json
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auth } from '@clerk/nextjs/server';
|
||||||
|
import { createFeedback } from '@/lib/db-actions';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const message = body?.message?.trim();
|
||||||
|
|
||||||
|
if (!message || message.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length > 2000) {
|
||||||
|
return NextResponse.json({ error: 'Message is too long' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await createFeedback({ message, userId: userId ?? undefined });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ const FIXED_DIMENSIONS = NEW_MODEL
|
|||||||
? { width: 896, height: 1200 }
|
? { width: 896, height: 1200 }
|
||||||
: { width: 864, height: 1184 };
|
: { width: 864, height: 1184 };
|
||||||
|
|
||||||
const TEXT_MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct";
|
const TEXT_MODEL = "Qwen/Qwen3.5-9B";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPagesGeneratedLast24Hours } from '@/lib/db-actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const pagesLast24h = await getPagesGeneratedLast24Hours();
|
||||||
|
return NextResponse.json({ pagesLast24h });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stats:', error);
|
||||||
|
return NextResponse.json({ pagesLast24h: 0 }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import Together from "together-ai";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { apiKey } = await request.json();
|
||||||
|
|
||||||
|
if (!apiKey || typeof apiKey !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "API key is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically fetch the fastest available model
|
||||||
|
const routerRes = await fetch("https://whichllm.together.ai/router/fast", {
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
});
|
||||||
|
const { model } = await routerRes.json();
|
||||||
|
|
||||||
|
// Fire a minimal completion — 1 output token to keep cost/latency negligible
|
||||||
|
const client = new Together({ apiKey });
|
||||||
|
await client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [{ role: "user", content: "hi" }],
|
||||||
|
max_tokens: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ valid: true });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const status =
|
||||||
|
typeof error === "object" && error !== null && "status" in error
|
||||||
|
? (error as { status: number }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return NextResponse.json({ valid: false, error: "Invalid API key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "Validation failed" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -40,8 +40,8 @@
|
|||||||
--sidebar-ring: oklch(0.6 0.2 270);
|
--sidebar-ring: oklch(0.6 0.2 270);
|
||||||
|
|
||||||
/* Accent colors */
|
/* Accent colors */
|
||||||
--indigo: oklch(0.6 0.2 270);
|
--indigo: oklch(0.6618 0.2214 36.88);
|
||||||
--indigo-light: oklch(0.7 0.15 270);
|
--indigo-light: oklch(0.7 0.15 36.88);
|
||||||
--emerald: oklch(0.65 0.2 160);
|
--emerald: oklch(0.65 0.2 160);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 29 KiB |
@@ -49,7 +49,10 @@ export default function RootLayout({
|
|||||||
className={`${inter.variable} ${bangers.variable} ${spaceGrotesk.variable} ${instrumentSerif.variable}`}
|
className={`${inter.variable} ${bangers.variable} ${spaceGrotesk.variable} ${instrumentSerif.variable}`}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<PlausibleProvider domain="makecomics.io" />
|
<PlausibleProvider
|
||||||
|
src="https://plausible.io/js/script.js"
|
||||||
|
scriptProps={{ "data-domain": "makecomics.io" }}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="font-sans antialiased">
|
<body className="font-sans antialiased">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Key, ExternalLink, ArrowRight, X } from "lucide-react"
|
import { Key, ExternalLink, ArrowRight, X, Check } from "lucide-react"
|
||||||
|
import { motion, AnimatePresence } from "motion/react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -24,10 +25,15 @@ interface ApiKeyModalProps {
|
|||||||
export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
||||||
const [apiKeyInput, setApiKeyInput] = useState("")
|
const [apiKeyInput, setApiKeyInput] = useState("")
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [shakeKey, setShakeKey] = useState(0)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
const [existingKey, setApiKey] = useApiKey()
|
const [existingKey, setApiKey] = useApiKey()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
setSuccess(false)
|
||||||
|
setError(null)
|
||||||
setApiKeyInput((current) => {
|
setApiKeyInput((current) => {
|
||||||
if (existingKey && current === "") {
|
if (existingKey && current === "") {
|
||||||
return existingKey
|
return existingKey
|
||||||
@@ -42,98 +48,179 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|||||||
if (!apiKeyInput.trim()) return
|
if (!apiKeyInput.trim()) return
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/validate-api-key", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ apiKey: apiKeyInput.trim() }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!data.valid) {
|
||||||
|
setError("Invalid API key. Please check and try again.")
|
||||||
|
setShakeKey((k) => k + 1)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Could not validate key. Please try again.")
|
||||||
|
setShakeKey((k) => k + 1)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setSuccess(true)
|
||||||
onSubmit(apiKeyInput.trim())
|
onSubmit(apiKeyInput.trim())
|
||||||
setApiKeyInput("")
|
setTimeout(() => {
|
||||||
|
setSuccess(false)
|
||||||
|
setApiKeyInput("")
|
||||||
|
}, 1400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
setApiKey(null)
|
setApiKey(null)
|
||||||
setApiKeyInput("")
|
setApiKeyInput("")
|
||||||
|
setError(null)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="border border-border/50 rounded-lg bg-background max-w-md">
|
<DialogContent className="border border-border/50 rounded-xl bg-background max-w-sm p-6 overflow-hidden">
|
||||||
<DialogHeader className="text-center">
|
<AnimatePresence>
|
||||||
<div className="mx-auto mb-4">
|
{success && (
|
||||||
<div className="w-14 h-14 glass-panel rounded-full flex items-center justify-center">
|
<motion.div
|
||||||
<Key className="w-6 h-6 text-indigo" />
|
key="success"
|
||||||
</div>
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-background rounded-xl"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 20, delay: 0.05 }}
|
||||||
|
className="w-14 h-14 rounded-full bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Check className="w-7 h-7 text-emerald-400" strokeWidth={2.5} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.15, duration: 0.2 }}
|
||||||
|
className="text-sm font-medium text-white"
|
||||||
|
>
|
||||||
|
API key saved
|
||||||
|
</motion.p>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.2 }}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
You're all set for unlimited generation
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Key className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<DialogTitle className="text-base font-semibold text-white leading-none">
|
||||||
|
{existingKey ? "Your API key" : "Add your API key"}
|
||||||
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogDescription className="text-sm text-muted-foreground leading-snug pl-6">
|
||||||
<DialogTitle className="text-xl text-center text-white">
|
|
||||||
{existingKey
|
{existingKey
|
||||||
? "Update your API key"
|
? "Update or remove your Together AI key."
|
||||||
: "Add your API key to continue"}
|
: "You've used all your free credits. Add your key for unlimited use."}
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="text-center text-muted-foreground">
|
|
||||||
{existingKey
|
|
||||||
? "Update your Together API key or add a new one. You can also delete your existing key."
|
|
||||||
: "You've used all your weekly credits! 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-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<AnimatePresence>
|
||||||
type="password"
|
{error && (
|
||||||
value={apiKeyInput}
|
<motion.p
|
||||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
key="error"
|
||||||
placeholder={
|
initial={{ opacity: 0, y: 4 }}
|
||||||
existingKey ? "Your current API key" : "Enter your API key..."
|
animate={{ opacity: 1, y: 0 }}
|
||||||
}
|
exit={{ opacity: 0, y: 4 }}
|
||||||
className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5 pr-10"
|
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||||
/>
|
className="absolute bottom-full left-0 mb-1.5 text-xs text-red-400 flex items-center gap-1.5 pointer-events-none"
|
||||||
{apiKeyInput && (
|
>
|
||||||
<button
|
<span className="inline-block w-1 h-1 rounded-full bg-red-400 flex-shrink-0" />
|
||||||
type="button"
|
{error}
|
||||||
onClick={() => setApiKeyInput("")}
|
</motion.p>
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-white transition-colors"
|
)}
|
||||||
>
|
</AnimatePresence>
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
<motion.div
|
||||||
)}
|
key={shakeKey}
|
||||||
|
className="relative"
|
||||||
|
animate={shakeKey > 0 ? { x: [0, -8, 8, -6, 6, -3, 3, 0] } : {}}
|
||||||
|
transition={{ duration: 0.45, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={apiKeyInput}
|
||||||
|
onChange={(e) => { setApiKeyInput(e.target.value); setError(null) }}
|
||||||
|
placeholder="sk-••••••••••••••••"
|
||||||
|
className={`bg-secondary border-border/50 text-white placeholder-muted-foreground/40 py-5 pr-10 font-mono text-sm transition-colors duration-200 ${error ? "border-red-500/60 focus-visible:ring-red-500/20" : ""}`}
|
||||||
|
/>
|
||||||
|
{apiKeyInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApiKeyInput("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={TOGETHER_LINK}
|
href={TOGETHER_LINK}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-indigo hover:text-indigo-light flex items-center gap-1.5 transition-colors"
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Get your Together API key
|
Get a free Together AI key
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
onClick={existingKey ? handleDelete : onClose}
|
onClick={existingKey ? handleDelete : onClose}
|
||||||
className="flex-1 text-muted-foreground hover:text-white hover:bg-secondary"
|
className={`flex-1 border-border/50 hover:border-border transition-colors ${
|
||||||
|
existingKey
|
||||||
|
? "text-red-400 border-red-500/30 hover:bg-red-500/10 hover:border-red-500/50 hover:text-red-300"
|
||||||
|
: "text-muted-foreground hover:text-white hover:bg-secondary"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{existingKey ? "Delete API Key" : "Maybe Later"}
|
{existingKey ? "Delete" : "Later"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!apiKeyInput.trim() || isLoading}
|
disabled={!apiKeyInput.trim() || isLoading}
|
||||||
className="flex-1 gap-2 bg-white hover:bg-neutral-200 text-black"
|
className="flex-[2] gap-2 bg-white hover:bg-neutral-200 text-black font-medium"
|
||||||
>
|
>
|
||||||
{isLoading ? "Validating..." : "Continue"}
|
{isLoading ? "Checking…" : "Save key"}
|
||||||
<ArrowRight className="w-4 h-4" />
|
{!isLoading && <ArrowRight className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-4 p-3 glass-panel rounded-lg">
|
<p className="text-xs text-muted-foreground/50 text-center pt-1">
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
Stored locally · never sent to our servers
|
||||||
Your API key is stored locally and never stored on our servers.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ export function ComicCanvas({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-3 mt-4">
|
<div className="flex flex-col items-center gap-3 mt-4">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground md:hidden">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
|||||||
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils";
|
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils";
|
||||||
import { useS3Upload } from "next-s3-upload";
|
import { useS3Upload } from "next-s3-upload";
|
||||||
import { isContentPolicyViolation } from "@/lib/utils";
|
import { isContentPolicyViolation } from "@/lib/utils";
|
||||||
|
import { MAX_SYSTEM_LENGTH, MAX_USER_PROMPT } from "@/lib/prompt";
|
||||||
|
|
||||||
interface CharacterItem {
|
interface CharacterItem {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -330,13 +331,19 @@ export function GeneratePageModal({
|
|||||||
<textarea
|
<textarea
|
||||||
autoFocus
|
autoFocus
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
|
onChange={(e) => setPrompt(e.target.value.slice(
|
||||||
|
// Only allow users to type up to MAX_SYSTEM_LENGTH characters in the prompt.
|
||||||
|
// Ensure users cannot paste or otherwise enter more text than the max.
|
||||||
|
0, MAX_USER_PROMPT))
|
||||||
|
}
|
||||||
placeholder={
|
placeholder={
|
||||||
isRedrawMode
|
isRedrawMode
|
||||||
? "Tweak the prompt to improve this page..."
|
? "Tweak the prompt to improve this page..."
|
||||||
: "Continue the story... Describe what happens next."
|
: "Continue the story... Describe what happens next."
|
||||||
}
|
}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
|
maxLength={MAX_USER_PROMPT}
|
||||||
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-20 leading-relaxed tracking-tight"
|
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-20 leading-relaxed tracking-tight"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -358,11 +365,10 @@ export function GeneratePageModal({
|
|||||||
onClick={() => toggleCharacterSelection(index)}
|
onClick={() => toggleCharacterSelection(index)}
|
||||||
onDoubleClick={() => setShowPreview(imageUrl)}
|
onDoubleClick={() => setShowPreview(imageUrl)}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className={`w-10 h-10 rounded-md overflow-hidden transition-all disabled:opacity-50 disabled:cursor-not-allowed relative ${
|
className={`w-10 h-10 rounded-md overflow-hidden transition-all disabled:opacity-50 disabled:cursor-not-allowed relative ${isSelected
|
||||||
isSelected
|
? "border-2 border-indigo-500"
|
||||||
? "border-2 border-indigo-500"
|
: "border-2 border-transparent hover:border-indigo/50"
|
||||||
: "border-2 border-transparent hover:border-indigo/50"
|
}`}
|
||||||
}`}
|
|
||||||
title="Click to select/deselect, double-click to preview"
|
title="Click to select/deselect, double-click to preview"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -432,7 +438,7 @@ export function GeneratePageModal({
|
|||||||
{isRedrawMode
|
{isRedrawMode
|
||||||
? "Previous pages and characters automatically referenced."
|
? "Previous pages and characters automatically referenced."
|
||||||
: "Previous page automatically referenced. " +
|
: "Previous page automatically referenced. " +
|
||||||
`${selectedCharacterIndices.size} selected characters.`}
|
`${selectedCharacterIndices.size} selected characters.`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Plus, Loader2, Key, Trash2 } from "lucide-react";
|
import { Plus, Loader2, Key, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { UserButton, SignedIn } from "@clerk/nextjs";
|
import { UserButton, Show } from "@clerk/nextjs";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,63 +33,65 @@ export function PageSidebar({
|
|||||||
isOwner = true,
|
isOwner = true,
|
||||||
}: PageSidebarProps) {
|
}: PageSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="hidden md:flex md:flex-col md:items-center w-24 border-r border-border/50 bg-background/50 py-4 gap-2 justify-between">
|
<aside className="hidden md:flex md:flex-col md:items-center w-24 border-r border-border/50 bg-background/50 py-4 gap-2 justify-between h-full">
|
||||||
{/* Top section: page thumbnails */}
|
{/* Top section: page thumbnails */}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex-1 w-full overflow-y-auto min-h-0 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-muted-foreground/60 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:hover:bg-muted-foreground">
|
||||||
{pages.map((page, index) => (
|
<div className="flex flex-col items-center gap-3 px-2 py-1">
|
||||||
<button
|
{pages.map((page, index) => (
|
||||||
key={page.id}
|
<button
|
||||||
onClick={() => onPageSelect(index)}
|
key={page.id}
|
||||||
disabled={loadingPageId === index}
|
onClick={() => onPageSelect(index)}
|
||||||
className={`
|
disabled={loadingPageId === index}
|
||||||
w-16 h-16 rounded-lg transition-all relative overflow-hidden
|
className={`
|
||||||
${
|
w-16 h-16 rounded-lg transition-all relative overflow-hidden flex-shrink-0
|
||||||
currentPage === index
|
${
|
||||||
? "ring-2 ring-indigo shadow-lg shadow-indigo/20"
|
currentPage === index
|
||||||
: "glass-panel glass-panel-hover hover:ring-1 hover:ring-white/20"
|
? "ring-2 ring-indigo shadow-lg shadow-indigo/20"
|
||||||
}
|
: "glass-panel glass-panel-hover hover:ring-1 hover:ring-white/20"
|
||||||
${loadingPageId === index ? "opacity-50" : ""}
|
}
|
||||||
`}
|
${loadingPageId === index ? "opacity-50" : ""}
|
||||||
>
|
`}
|
||||||
{loadingPageId === index ? (
|
>
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
{loadingPageId === index ? (
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-white" />
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
</div>
|
<Loader2 className="w-6 h-6 animate-spin text-white" />
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src={page.image || "/placeholder.svg"}
|
|
||||||
alt={`Page ${index + 1}`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
absolute bottom-1 left-1 px-1.5 py-0.5 rounded text-[10px] font-medium tracking-tight
|
|
||||||
${
|
|
||||||
currentPage === index
|
|
||||||
? "bg-indigo text-white"
|
|
||||||
: "bg-black/70 text-white"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
</button>
|
<img
|
||||||
))}
|
src={page.image || "/placeholder.svg"}
|
||||||
|
alt={`Page ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute bottom-1 left-1 px-1.5 py-0.5 rounded text-[10px] font-medium tracking-tight
|
||||||
|
${
|
||||||
|
currentPage === index
|
||||||
|
? "bg-indigo text-white"
|
||||||
|
: "bg-black/70 text-white"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<button
|
<button
|
||||||
onClick={onAddPage}
|
onClick={onAddPage}
|
||||||
className="w-16 h-16 rounded-lg border-2 border-dashed border-border/50 hover:border-indigo/50 bg-background/50 hover:bg-background/80 transition-all group flex items-center justify-center"
|
className="w-16 h-16 rounded-lg border-2 border-dashed border-border/50 hover:border-indigo/50 bg-background/50 hover:bg-background/80 transition-all group flex items-center justify-center flex-shrink-0"
|
||||||
>
|
>
|
||||||
<Plus className="w-6 h-6 text-muted-foreground group-hover:text-indigo transition-transform" />
|
<Plus className="w-6 h-6 text-muted-foreground group-hover:text-indigo transition-transform" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3 pt-2">
|
||||||
{/* API Key Button */}
|
{/* API Key Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={onApiKeyClick}
|
onClick={onApiKeyClick}
|
||||||
@@ -101,7 +103,7 @@ export function PageSidebar({
|
|||||||
<Key className="w-4 h-4" />
|
<Key className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<SignedIn>
|
<Show when="signed-in">
|
||||||
<div className="w-10 h-10 glass-panel glass-panel-hover rounded-md flex items-center justify-center text-muted-foreground hover:text-white transition-colors">
|
<div className="w-10 h-10 glass-panel glass-panel-hover rounded-md flex items-center justify-center text-muted-foreground hover:text-white transition-colors">
|
||||||
<UserButton
|
<UserButton
|
||||||
appearance={{
|
appearance={{
|
||||||
@@ -111,7 +113,7 @@ export function PageSidebar({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SignedIn>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { MessageSquare, ArrowRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface FeedbackModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!message.trim()) return;
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/feedback", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: message.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
setStatus("success");
|
||||||
|
setMessage("");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setMessage("");
|
||||||
|
setStatus("idle");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="border border-border/50 rounded-lg bg-background max-w-md">
|
||||||
|
<DialogHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4">
|
||||||
|
<div className="w-14 h-14 glass-panel rounded-full flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-6 h-6 text-indigo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-xl text-center text-white">
|
||||||
|
Share your feedback
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-center text-muted-foreground">
|
||||||
|
What do you think? Any bugs, ideas, or feature requests are welcome.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{status === "success" ? (
|
||||||
|
<div className="mt-4 p-4 glass-panel rounded-lg text-center">
|
||||||
|
<p className="text-white text-sm font-medium">Thanks for your feedback!</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-1">We really appreciate it.</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="mt-4 bg-white hover:bg-neutral-200 text-black"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||||
|
<Textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Your feedback..."
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
className="bg-secondary border-border/50 text-white placeholder-muted-foreground resize-none"
|
||||||
|
/>
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-red-400 text-xs">Something went wrong. Please try again.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 text-muted-foreground hover:text-white hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!message.trim() || status === "loading"}
|
||||||
|
className="flex-1 gap-2 bg-white hover:bg-neutral-200 text-black"
|
||||||
|
>
|
||||||
|
{status === "loading" ? "Sending..." : "Send Feedback"}
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ 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";
|
import { ApiKeyModal } from "@/components/api-key-modal";
|
||||||
|
import { MAX_SYSTEM_LENGTH, MAX_USER_PROMPT } from "@/lib/prompt";
|
||||||
|
|
||||||
interface ComicCreationFormProps {
|
interface ComicCreationFormProps {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -328,9 +329,12 @@ export function ComicCreationForm({
|
|||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value.slice(
|
||||||
|
0, MAX_USER_PROMPT))
|
||||||
|
}
|
||||||
placeholder="A cyberpunk detective standing in neon rain, holding a glowing datapad, moody lighting, noir style..."
|
placeholder="A cyberpunk detective standing in neon rain, holding a glowing datapad, moody lighting, noir style..."
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
maxLength={MAX_USER_PROMPT}
|
||||||
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-16 leading-relaxed disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-16 leading-relaxed disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -424,8 +428,8 @@ export function ComicCreationForm({
|
|||||||
setShowStyleDropdown(false);
|
setShowStyleDropdown(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-3 py-2 rounded text-xs transition-colors flex items-center justify-between ${style === styleOption.id
|
className={`w-full text-left px-3 py-2 rounded text-xs transition-colors flex items-center justify-between ${style === styleOption.id
|
||||||
? "bg-indigo/10 text-indigo"
|
? "bg-indigo/10 text-indigo"
|
||||||
: "text-muted-foreground hover:bg-white/5 hover:text-white"
|
: "text-muted-foreground hover:bg-white/5 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{styleOption.name}</span>
|
<span>{styleOption.name}</span>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { TOGETHER_LINK } from "@/lib/utils";
|
import { TOGETHER_LINK } from "@/lib/utils";
|
||||||
import { Github } from "lucide-react";
|
import { MessageSquare } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { FeedbackModal } from "@/components/feedback-modal";
|
||||||
|
|
||||||
|
function GithubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function XIcon({ className }: { className?: string }) {
|
function XIcon({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -11,39 +23,52 @@ function XIcon({ className }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const [showFeedback, setShowFeedback] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="h-8 border-t border-border/50 bg-background flex items-center justify-between px-6 text-[10px] text-muted-foreground select-none">
|
<>
|
||||||
<div className="flex items-center gap-4">
|
<footer className="h-8 border-t border-border/50 bg-background flex items-center justify-between px-6 text-[10px] text-muted-foreground select-none">
|
||||||
<span>
|
<div className="flex items-center gap-4">
|
||||||
Made & powered by{" "}
|
<span>
|
||||||
|
Made & powered by{" "}
|
||||||
|
<Link
|
||||||
|
href={TOGETHER_LINK}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-white transition-colors text-white"
|
||||||
|
>
|
||||||
|
Together.ai
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFeedback(true)}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-border/50 hover:border-border hover:text-white transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
Got ideas? Tell us
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={TOGETHER_LINK}
|
href="https://github.com/nutlope/make-comics"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-white transition-colors text-white"
|
className="hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Together.ai
|
<GithubIcon className="w-3.5 h-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
<Link
|
||||||
</div>
|
href="https://x.com/nutlope"
|
||||||
<div className="flex items-center gap-3">
|
target="_blank"
|
||||||
<Link
|
rel="noopener noreferrer"
|
||||||
href="https://github.com/nutlope/make-comics"
|
className="hover:text-white transition-colors"
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
<XIcon className="w-3.5 h-3.5" />
|
||||||
className="hover:text-white transition-colors"
|
</Link>
|
||||||
>
|
</div>
|
||||||
<Github className="w-3.5 h-3.5" />
|
</footer>
|
||||||
</Link>
|
|
||||||
<Link
|
<FeedbackModal isOpen={showFeedback} onClose={() => setShowFeedback(false)} />
|
||||||
href="https://x.com/nutlope"
|
</>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<XIcon className="w-3.5 h-3.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { TOGETHER_LINK } from "@/lib/utils";
|
import { TOGETHER_LINK } from "@/lib/utils";
|
||||||
|
|
||||||
export function LandingHero() {
|
export function LandingHero() {
|
||||||
|
const [pagesLast24h, setPagesLast24h] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetch("/api/stats")
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled && data && typeof data.pagesLast24h === "number") {
|
||||||
|
setPagesLast24h(data.pagesLast24h);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const roundedCount =
|
||||||
|
pagesLast24h !== null && pagesLast24h >= 10
|
||||||
|
? Math.floor(pagesLast24h / 10) * 10
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="relative py-8 sm:py-12 md:py-16 lg:py-0">
|
<header className="relative py-8 sm:py-12 md:py-16 lg:py-0">
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
@@ -11,11 +34,12 @@ export function LandingHero() {
|
|||||||
href={TOGETHER_LINK}
|
href={TOGETHER_LINK}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border glass-panel mb-4 sm:mb-6 w-fit"
|
className="flex justify-center items-center gap-1 px-2.5 py-1 rounded-full border border-border glass-panel mb-4 sm:mb-6 w-fit"
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-medium text-muted-foreground tracking-[-0.015em]">
|
<span className="text-[10px] font-medium text-muted-foreground tracking-[-0.015em]">
|
||||||
Powered by Together AI
|
Powered by
|
||||||
</span>
|
</span>
|
||||||
|
<img src="/poweredby.png" className="h-[18px]"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl text-foreground uppercase mb-4 sm:mb-5 tracking-wide font-heading font-semibold leading-tight sm:leading-[5.2rem]">
|
<h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl text-foreground uppercase mb-4 sm:mb-5 tracking-wide font-heading font-semibold leading-tight sm:leading-[5.2rem]">
|
||||||
@@ -27,6 +51,16 @@ export function LandingHero() {
|
|||||||
Describe your scene, choose a style, and let AI render professional
|
Describe your scene, choose a style, and let AI render professional
|
||||||
comic panels instantly.
|
comic panels instantly.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{roundedCount !== null && (
|
||||||
|
<p className="text-muted-foreground leading-relaxed max-w-md mx-auto lg:mx-0 tracking-[-0.02em] px-4 sm:px-0 text-sm mt-2">
|
||||||
|
More than{" "}
|
||||||
|
<span className="text-indigo font-semibold">
|
||||||
|
{roundedCount.toLocaleString()}
|
||||||
|
</span>{" "}
|
||||||
|
comic pages have been generated in the last 24 hours.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -2,10 +2,18 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Github, Key, BookOpen, User, Plus } from "lucide-react";
|
import { Key, BookOpen, User, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
function GithubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ApiKeyModal } from "@/components/api-key-modal";
|
import { ApiKeyModal } from "@/components/api-key-modal";
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, Show, useAuth } from "@clerk/nextjs";
|
||||||
import { useApiKey } from "@/hooks/use-api-key";
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
@@ -61,7 +69,7 @@ export function Navbar() {
|
|||||||
>
|
>
|
||||||
<div className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center">
|
<div className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src="/images/makecomics-logo.png"
|
src="/images/makecomics-logo.svg"
|
||||||
alt="MakeComics Logo"
|
alt="MakeComics Logo"
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
/>
|
/>
|
||||||
@@ -88,13 +96,13 @@ export function Navbar() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 glass-panel glass-panel-hover transition-all text-xs rounded-md"
|
className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 glass-panel glass-panel-hover transition-all text-xs rounded-md"
|
||||||
>
|
>
|
||||||
<Github className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<GithubIcon className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
<span className="text-muted-foreground text-xs sm:text-sm hidden sm:inline">
|
<span className="text-muted-foreground text-xs sm:text-sm hidden sm:inline">
|
||||||
{stars}
|
{stars}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<SignedOut>
|
<Show when="signed-out">
|
||||||
<SignInButton mode="modal">
|
<SignInButton mode="modal">
|
||||||
<button className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 glass-panel glass-panel-hover transition-all text-xs rounded-md cursor-pointer">
|
<button className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 glass-panel glass-panel-hover transition-all text-xs rounded-md cursor-pointer">
|
||||||
<span className="text-muted-foreground text-xs sm:text-sm hidden sm:inline tracking-tight">
|
<span className="text-muted-foreground text-xs sm:text-sm hidden sm:inline tracking-tight">
|
||||||
@@ -102,8 +110,8 @@ export function Navbar() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</SignInButton>
|
</SignInButton>
|
||||||
</SignedOut>
|
</Show>
|
||||||
<SignedIn>
|
<Show when="signed-in">
|
||||||
{isOnStoriesPage ? (
|
{isOnStoriesPage ? (
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<button className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 bg-white hover:bg-neutral-200 text-black transition-all text-xs rounded-md cursor-pointer font-medium">
|
<button className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 bg-white hover:bg-neutral-200 text-black transition-all text-xs rounded-md cursor-pointer font-medium">
|
||||||
@@ -123,7 +131,7 @@ export function Navbar() {
|
|||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</SignedIn>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function Calendar({
|
|||||||
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
||||||
defaultClassNames.caption_label,
|
defaultClassNames.caption_label,
|
||||||
),
|
),
|
||||||
table: 'w-full border-collapse',
|
month_grid: 'w-full border-collapse',
|
||||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE "feedback" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
import { stories, pages, type Story, type Page } from './schema';
|
import { stories, pages, feedback, type Story, type Page, type Feedback } from './schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq, gte, isNotNull, sql } from 'drizzle-orm';
|
||||||
import { generateComicSlug } from './slug-generator';
|
import { generateComicSlug } from './slug-generator';
|
||||||
|
|
||||||
export async function createStory(data: { title: string; description?: string; userId: string; style?: string; usesOwnApiKey?: boolean }): Promise<Story> {
|
export async function createStory(data: { title: string; description?: string; userId: string; style?: string; usesOwnApiKey?: boolean }): Promise<Story> {
|
||||||
@@ -150,3 +150,17 @@ export async function deletePage(pageId: string): Promise<void> {
|
|||||||
export async function deleteStory(storyId: string): Promise<void> {
|
export async function deleteStory(storyId: string): Promise<void> {
|
||||||
await db.delete(stories).where(eq(stories.id, storyId));
|
await db.delete(stories).where(eq(stories.id, storyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createFeedback(data: { message: string; userId?: string }): Promise<Feedback> {
|
||||||
|
const [entry] = await db.insert(feedback).values(data).returning();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPagesGeneratedLast24Hours(): Promise<number> {
|
||||||
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(pages)
|
||||||
|
.where(and(isNotNull(pages.generatedImageUrl), gte(pages.createdAt, since)));
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { COMIC_STYLES } from "./constants";
|
import { COMIC_STYLES } from "./constants";
|
||||||
|
|
||||||
|
// Together AI has a 45000 character limit for the prompt parameter
|
||||||
|
// We use 40k total to keep a larger safety margin
|
||||||
|
export const MAX_PROMPT_LENGTH = 40000;
|
||||||
|
export const MAX_SYSTEM_LENGTH = 35000; // Reserve 5,000 for user's prompt
|
||||||
|
|
||||||
|
export const MAX_USER_PROMPT = MAX_PROMPT_LENGTH - MAX_SYSTEM_LENGTH;
|
||||||
|
|
||||||
export function buildComicPrompt({
|
export function buildComicPrompt({
|
||||||
prompt,
|
prompt,
|
||||||
style,
|
style,
|
||||||
@@ -28,11 +35,37 @@ export function buildComicPrompt({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAddPage && previousPages.length > 0) {
|
if (isAddPage && previousPages.length > 0) {
|
||||||
const storyHistory = previousPages
|
// Limit previous pages to fit within MAX_SYSTEM_LENGTH
|
||||||
.map((page, index) => `Page ${index + 1}: ${page.prompt}`)
|
// Start with most recent pages and work backwards
|
||||||
.join("\n");
|
const header = `\nSTORY CONTINUATION CONTEXT:\nThis is page ${previousPages.length + 1} of an existing comic story. Here are the recent pages for context:\n`;
|
||||||
|
const footer = `\n\nThe new page should naturally continue this story. Maintain the same characters, setting, and narrative style. Reference previous events and build upon them.\n`;
|
||||||
|
|
||||||
continuationContext = `\nSTORY CONTINUATION CONTEXT:\nThis is a continuation of an existing comic story. Here are the previous pages:\n${storyHistory}\n\nThe new page should naturally continue this story. Maintain the same characters, setting, and narrative style. Reference previous events and build upon them.\n`;
|
// Calculate available space for previous pages (reserve space for rest of system prompt)
|
||||||
|
const basePromptLength = 2500; // Approximate length of system prompt without previous pages
|
||||||
|
const availableSpace = MAX_SYSTEM_LENGTH - basePromptLength - header.length - footer.length;
|
||||||
|
|
||||||
|
const selectedPages: string[] = [];
|
||||||
|
let currentLength = 0;
|
||||||
|
|
||||||
|
// Add pages from most recent backwards until we run out of space
|
||||||
|
for (let i = previousPages.length - 1; i >= 0; i--) {
|
||||||
|
const pageEntry = `Page ${i + 1}: ${previousPages[i].prompt}`;
|
||||||
|
const entryLength = pageEntry.length + (selectedPages.length > 0 ? 1 : 0); // +1 for newline
|
||||||
|
|
||||||
|
if (currentLength + entryLength <= availableSpace) {
|
||||||
|
selectedPages.unshift(pageEntry);
|
||||||
|
currentLength += entryLength;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPages.length > 0) {
|
||||||
|
continuationContext = header + selectedPages.join("\n") + footer;
|
||||||
|
} else {
|
||||||
|
// Fallback if no pages fit
|
||||||
|
continuationContext = `\nSTORY CONTINUATION CONTEXT:\nThis is page ${previousPages.length + 1} of an existing comic story with ${previousPages.length} previous pages. Continue the story maintaining consistency.\n`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let characterSection = "";
|
let characterSection = "";
|
||||||
@@ -69,16 +102,11 @@ CHARACTER CONSISTENCY RULES (HIGHEST PRIORITY):
|
|||||||
- Apply comic style to body/pose/action but preserve exact facial appearance
|
- Apply comic style to body/pose/action but preserve exact facial appearance
|
||||||
- Same character must look identical across all panels they appear in
|
- Same character must look identical across all panels they appear in
|
||||||
|
|
||||||
TEXT AND LETTERING (CRITICAL - MINIMAL TEXT POLICY):
|
TEXT AND LETTERING (CRITICAL):
|
||||||
- Keep text to an ABSOLUTE MINIMUM - comic panels should be primarily visual storytelling
|
- All text in speech bubbles must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled
|
||||||
- Maximum 1-2 speech bubbles per panel, often zero is better
|
|
||||||
- If text appears, use only 3-5 words maximum per bubble
|
|
||||||
- Prefer silent panels with expressive artwork over dialogue
|
|
||||||
- Show don't tell - convey emotion through facial expressions and body language
|
|
||||||
- All text must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled
|
|
||||||
- Use bold clean comic book lettering, large and easy to read
|
- Use bold clean comic book lettering, large and easy to read
|
||||||
- Speech bubbles: crisp white fill, solid black outline, pointed tail toward speaker
|
- Speech bubbles: crisp white fill, solid black outline, pointed tail toward speaker
|
||||||
- NO paragraphs, long dialogue, or excessive text in any panel
|
- Keep dialogue SHORT: maximum 1-2 sentences per bubble
|
||||||
- NO blurry, warped, or unreadable text
|
- NO blurry, warped, or unreadable text
|
||||||
|
|
||||||
PAGE LAYOUT:
|
PAGE LAYOUT:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, text, integer, timestamp, uuid, jsonb, boolean } from 'drizzle-orm/pg-core';
|
import { pgTable, text, integer, timestamp, uuid, jsonb, boolean, serial } from 'drizzle-orm/pg-core';
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
// Stories table
|
// Stories table
|
||||||
@@ -38,7 +38,18 @@ export const pagesRelations = relations(pages, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Feedback table
|
||||||
|
export const feedback = pgTable('feedback', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
userId: text('user_id'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
export type Feedback = typeof feedback.$inferSelect;
|
||||||
|
export type NewFeedback = typeof feedback.$inferInsert;
|
||||||
|
|
||||||
export type Story = typeof stories.$inferSelect;
|
export type Story = typeof stories.$inferSelect;
|
||||||
export type NewStory = typeof stories.$inferInsert;
|
export type NewStory = typeof stories.$inferInsert;
|
||||||
|
|
||||||
|
|||||||
@@ -9,78 +9,79 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.958.0",
|
"@aws-sdk/client-s3": "^3.1070.0",
|
||||||
"@clerk/nextjs": "^6.36.10",
|
"@clerk/nextjs": "^7.5.3",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@neondatabase/serverless": "^1.0.2",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@next/env": "^16.1.1",
|
"@next/env": "^16.2.9",
|
||||||
"@radix-ui/react-accordion": "1.2.2",
|
"@radix-ui/react-accordion": "1.2.14",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
"@radix-ui/react-alert-dialog": "1.1.17",
|
||||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
"@radix-ui/react-aspect-ratio": "1.1.10",
|
||||||
"@radix-ui/react-avatar": "1.1.2",
|
"@radix-ui/react-avatar": "1.2.0",
|
||||||
"@radix-ui/react-checkbox": "1.1.3",
|
"@radix-ui/react-checkbox": "1.3.5",
|
||||||
"@radix-ui/react-collapsible": "1.1.2",
|
"@radix-ui/react-collapsible": "1.1.14",
|
||||||
"@radix-ui/react-context-menu": "2.2.4",
|
"@radix-ui/react-context-menu": "2.3.1",
|
||||||
"@radix-ui/react-dialog": "1.1.4",
|
"@radix-ui/react-dialog": "1.1.17",
|
||||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
"@radix-ui/react-dropdown-menu": "2.1.18",
|
||||||
"@radix-ui/react-hover-card": "1.1.4",
|
"@radix-ui/react-hover-card": "1.1.17",
|
||||||
"@radix-ui/react-label": "2.1.1",
|
"@radix-ui/react-label": "2.1.10",
|
||||||
"@radix-ui/react-menubar": "1.1.4",
|
"@radix-ui/react-menubar": "1.1.18",
|
||||||
"@radix-ui/react-navigation-menu": "1.2.3",
|
"@radix-ui/react-navigation-menu": "1.2.16",
|
||||||
"@radix-ui/react-popover": "1.1.4",
|
"@radix-ui/react-popover": "1.1.17",
|
||||||
"@radix-ui/react-progress": "1.1.1",
|
"@radix-ui/react-progress": "1.1.10",
|
||||||
"@radix-ui/react-radio-group": "1.2.2",
|
"@radix-ui/react-radio-group": "1.4.1",
|
||||||
"@radix-ui/react-scroll-area": "1.2.2",
|
"@radix-ui/react-scroll-area": "1.2.12",
|
||||||
"@radix-ui/react-select": "2.1.4",
|
"@radix-ui/react-select": "2.3.1",
|
||||||
"@radix-ui/react-separator": "1.1.1",
|
"@radix-ui/react-separator": "1.1.10",
|
||||||
"@radix-ui/react-slider": "1.2.2",
|
"@radix-ui/react-slider": "1.4.1",
|
||||||
"@radix-ui/react-slot": "1.1.1",
|
"@radix-ui/react-slot": "1.3.0",
|
||||||
"@radix-ui/react-switch": "1.1.2",
|
"@radix-ui/react-switch": "1.3.1",
|
||||||
"@radix-ui/react-tabs": "1.1.2",
|
"@radix-ui/react-tabs": "1.1.15",
|
||||||
"@radix-ui/react-toast": "1.2.4",
|
"@radix-ui/react-toast": "1.2.17",
|
||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.12",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.13",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.2.10",
|
||||||
"@upstash/ratelimit": "^2.0.7",
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
"@upstash/redis": "^1.36.0",
|
"@upstash/redis": "^1.38.0",
|
||||||
"@vercel/analytics": "1.3.1",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.4.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.2",
|
||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.6.0",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.2",
|
||||||
"jspdf": "^3.0.4",
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^1.20.0",
|
||||||
"next": "16.1.5",
|
"motion": "^12.40.0",
|
||||||
"next-plausible": "^3.12.5",
|
"next": "16.2.9",
|
||||||
|
"next-plausible": "^4.0.0",
|
||||||
"next-s3-upload": "^0.3.4",
|
"next-s3-upload": "^0.3.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.7",
|
||||||
"react-day-picker": "9.8.0",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.7",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^4.11.2",
|
||||||
"recharts": "2.15.4",
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"together-ai": "^0.33.0",
|
"together-ai": "^0.40.0",
|
||||||
"use-local-storage-state": "^19.5.0",
|
"use-local-storage-state": "^19.5.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "4.2.1"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.10",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.3.1",
|
||||||
"tw-animate-css": "1.3.3",
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@clerk/shared': true
|
||||||
|
core-js: true
|
||||||
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@aws-sdk/client-s3@3.1070.0'
|
||||||
|
- lucide-react@1.20.0
|
||||||
|
overrides:
|
||||||
|
fast-xml-parser: '>=5.3.6'
|
||||||
|
dompurify: '>=3.3.2'
|
||||||
|
lodash: '>=4.17.24'
|
||||||
|
postcss: '>=8.5.10'
|
||||||
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 184 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<style>
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.background { fill: black; }
|
|
||||||
.foreground { fill: white; }
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.background { fill: white; }
|
|
||||||
.foreground { fill: black; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<g clip-path="url(#clip0_7960_43945)">
|
|
||||||
<rect class="background" width="180" height="180" rx="37" />
|
|
||||||
<g style="transform: scale(95%); transform-origin: center">
|
|
||||||
<path class="foreground"
|
|
||||||
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
|
||||||
<path class="foreground"
|
|
||||||
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_7960_43945">
|
|
||||||
<rect width="180" height="180" fill="white" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 950 KiB After Width: | Height: | Size: 525 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 268 KiB |