Compare commits

..

10 Commits

Author SHA1 Message Date
Riccardo Giorato fbdf84fca7 feat: add api key validation endpoint and update comic generation model 2026-06-17 10:09:40 +02:00
Riccardo Giorato 624a729b24 feat: update deps, add pnpm-workspace, modify components 2026-06-17 09:43:26 +02:00
Riccardo Giorato e18f67a7d0 Merge pull request #5 from Nutlope/orchestrator/update-nextjs-version-jx78w44g
Update Next.js to 16.2.6
2026-05-08 14:59:16 +02:00
orchestrator-build[bot] 3270f38d94 Update Next.js from 16.1.5 to 16.2.6 2026-05-08 12:53:18 +00:00
Riccardo Giorato 62f8a0ca35 feat: add feedback system with modal, api endpoints, and stats tracking 2026-04-16 22:02:03 +02:00
Riccardo Giorato 200a4e2a29 new together branding 2026-03-03 16:25:49 +01:00
Riccardo Giorato 9cf3e31af2 Enforce MAX_USER_PROMPT limit in generate-page-modal and comic-creation-form 2026-02-11 11:37:28 +01:00
Riccardo Giorato a05fdea297 Add scrollable page thumbnails with scrollbar styling in page sidebar 2026-02-11 11:27:04 +01:00
Riccardo Giorato a56a21bc87 Update comic-canvas.tsx 2026-02-11 10:50:58 +01:00
Riccardo Giorato 4dc2266a7e Revert "Update prompt.ts"
This reverts commit dd67b14a9c.
2026-01-30 14:17:50 +01:00
35 changed files with 3129 additions and 1650 deletions
+4
View File
@@ -27,3 +27,7 @@ next-env.d.ts
/.clerk/ /.clerk/
.DS_Store .DS_Store
IDEAS.md
.claude/launch.json
+22
View File
@@ -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 });
}
+1 -1
View File
@@ -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 {
+15
View File
@@ -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 });
}
}
+45
View File
@@ -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 },
);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2 -2
View File
@@ -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);
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+4 -1
View File
@@ -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}
+140 -53
View File
@@ -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>
) )
+2 -2
View File
@@ -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"
+13 -7
View File
@@ -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
+57 -55
View File
@@ -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>
); );
+113
View File
@@ -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>
);
}
+7 -3
View File
@@ -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>
+54 -29
View File
@@ -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>
); );
} }
+36 -2
View File
@@ -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>
+16 -8
View File
@@ -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>
+1 -1
View File
@@ -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',
+6
View File
@@ -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
);
+16 -2
View File
@@ -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;
}
+40 -12
View File
@@ -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:
+12 -1
View File
@@ -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;
+60 -59
View File
@@ -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"
} }
} }
+2405 -1385
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 184 KiB

-26
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

After

Width:  |  Height:  |  Size: 525 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 162 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 268 KiB