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/
.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: 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) {
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);
/* Accent colors */
--indigo: oklch(0.6 0.2 270);
--indigo-light: oklch(0.7 0.15 270);
--indigo: oklch(0.6618 0.2214 36.88);
--indigo-light: oklch(0.7 0.15 36.88);
--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}`}
>
<head>
<PlausibleProvider domain="makecomics.io" />
<PlausibleProvider
src="https://plausible.io/js/script.js"
scriptProps={{ "data-domain": "makecomics.io" }}
/>
</head>
<body className="font-sans antialiased">
{children}
+140 -53
View File
@@ -2,7 +2,8 @@
import type React 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 { Input } from "@/components/ui/input"
import {
@@ -24,10 +25,15 @@ interface ApiKeyModalProps {
export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
const [apiKeyInput, setApiKeyInput] = useState("")
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()
useEffect(() => {
if (isOpen) {
setSuccess(false)
setError(null)
setApiKeyInput((current) => {
if (existingKey && current === "") {
return existingKey
@@ -42,98 +48,179 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
if (!apiKeyInput.trim()) return
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)
setSuccess(true)
onSubmit(apiKeyInput.trim())
setApiKeyInput("")
setTimeout(() => {
setSuccess(false)
setApiKeyInput("")
}, 1400)
}
const handleDelete = () => {
setApiKey(null)
setApiKeyInput("")
setError(null)
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<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">
<Key className="w-6 h-6 text-indigo" />
</div>
<DialogContent className="border border-border/50 rounded-xl bg-background max-w-sm p-6 overflow-hidden">
<AnimatePresence>
{success && (
<motion.div
key="success"
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>
<DialogTitle className="text-xl text-center text-white">
<DialogDescription className="text-sm text-muted-foreground leading-snug pl-6">
{existingKey
? "Update your API key"
: "Add your API key to continue"}
</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."}
? "Update or remove your Together AI key."
: "You've used all your free credits. Add your key for unlimited use."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<form onSubmit={handleSubmit} className="space-y-3">
<div className="relative">
<Input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder={
existingKey ? "Your current API key" : "Enter your API key..."
}
className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5 pr-10"
/>
{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>
)}
<AnimatePresence>
{error && (
<motion.p
key="error"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
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"
>
<span className="inline-block w-1 h-1 rounded-full bg-red-400 flex-shrink-0" />
{error}
</motion.p>
)}
</AnimatePresence>
<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>
<a
href={TOGETHER_LINK}
target="_blank"
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
<ExternalLink className="h-3.5 w-3.5" />
Get a free Together AI key
<ExternalLink className="h-3 w-3" />
</a>
<div className="flex gap-3 pt-2">
<div className="flex gap-2 pt-1">
<Button
type="button"
variant="ghost"
variant="outline"
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
type="submit"
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"}
<ArrowRight className="w-4 h-4" />
{isLoading ? "Checking" : "Save key"}
{!isLoading && <ArrowRight className="w-4 h-4" />}
</Button>
</div>
</form>
<div className="mt-4 p-3 glass-panel rounded-lg">
<p className="text-xs text-muted-foreground text-center">
Your API key is stored locally and never stored on our servers.
<p className="text-xs text-muted-foreground/50 text-center pt-1">
Stored locally · never sent to our servers
</p>
</div>
</form>
</DialogContent>
</Dialog>
)
+2 -2
View File
@@ -122,8 +122,8 @@ export function ComicCanvas({
)}
</div>
<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 flex-col items-center gap-3 mt-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Button
variant="ghost"
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 { useS3Upload } from "next-s3-upload";
import { isContentPolicyViolation } from "@/lib/utils";
import { MAX_SYSTEM_LENGTH, MAX_USER_PROMPT } from "@/lib/prompt";
interface CharacterItem {
url: string;
@@ -330,13 +331,19 @@ export function GeneratePageModal({
<textarea
autoFocus
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={
isRedrawMode
? "Tweak the prompt to improve this page..."
: "Continue the story... Describe what happens next."
}
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"
/>
@@ -358,11 +365,10 @@ export function GeneratePageModal({
onClick={() => toggleCharacterSelection(index)}
onDoubleClick={() => setShowPreview(imageUrl)}
disabled={isGenerating}
className={`w-10 h-10 rounded-md overflow-hidden transition-all disabled:opacity-50 disabled:cursor-not-allowed relative ${
isSelected
? "border-2 border-indigo-500"
: "border-2 border-transparent hover:border-indigo/50"
}`}
className={`w-10 h-10 rounded-md overflow-hidden transition-all disabled:opacity-50 disabled:cursor-not-allowed relative ${isSelected
? "border-2 border-indigo-500"
: "border-2 border-transparent hover:border-indigo/50"
}`}
title="Click to select/deselect, double-click to preview"
>
<img
@@ -432,7 +438,7 @@ export function GeneratePageModal({
{isRedrawMode
? "Previous pages and characters automatically referenced."
: "Previous page automatically referenced. " +
`${selectedCharacterIndices.size} selected characters.`}
`${selectedCharacterIndices.size} selected characters.`}
</div>
<Button
+57 -55
View File
@@ -2,7 +2,7 @@
import { Plus, Loader2, Key, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { UserButton, SignedIn } from "@clerk/nextjs";
import { UserButton, Show } from "@clerk/nextjs";
interface PageData {
id: number;
@@ -33,63 +33,65 @@ export function PageSidebar({
isOwner = true,
}: PageSidebarProps) {
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 */}
<div className="flex flex-col items-center gap-3">
{pages.map((page, index) => (
<button
key={page.id}
onClick={() => onPageSelect(index)}
disabled={loadingPageId === index}
className={`
w-16 h-16 rounded-lg transition-all relative overflow-hidden
${
currentPage === index
? "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 ? (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-white" />
</div>
) : (
<>
<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 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">
<div className="flex flex-col items-center gap-3 px-2 py-1">
{pages.map((page, index) => (
<button
key={page.id}
onClick={() => onPageSelect(index)}
disabled={loadingPageId === index}
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"
: "glass-panel glass-panel-hover hover:ring-1 hover:ring-white/20"
}
${loadingPageId === index ? "opacity-50" : ""}
`}
>
{loadingPageId === index ? (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-white" />
</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 && (
<button
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"
>
<Plus className="w-6 h-6 text-muted-foreground group-hover:text-indigo transition-transform" />
</button>
)}
{isOwner && (
<button
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 flex-shrink-0"
>
<Plus className="w-6 h-6 text-muted-foreground group-hover:text-indigo transition-transform" />
</button>
)}
</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 */}
<Button
onClick={onApiKeyClick}
@@ -101,7 +103,7 @@ export function PageSidebar({
<Key className="w-4 h-4" />
</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">
<UserButton
appearance={{
@@ -111,7 +113,7 @@ export function PageSidebar({
}}
/>
</div>
</SignedIn>
</Show>
</div>
</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 { isContentPolicyViolation } from "@/lib/utils";
import { ApiKeyModal } from "@/components/api-key-modal";
import { MAX_SYSTEM_LENGTH, MAX_USER_PROMPT } from "@/lib/prompt";
interface ComicCreationFormProps {
prompt: string;
@@ -328,9 +329,12 @@ export function ComicCreationForm({
<textarea
ref={textareaRef}
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..."
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"
/>
@@ -424,8 +428,8 @@ export function ComicCreationForm({
setShowStyleDropdown(false);
}}
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"
: "text-muted-foreground hover:bg-white/5 hover:text-white"
? "bg-indigo/10 text-indigo"
: "text-muted-foreground hover:bg-white/5 hover:text-white"
}`}
>
<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 { Github } from "lucide-react";
import { MessageSquare } from "lucide-react";
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 }) {
return (
@@ -11,39 +23,52 @@ function XIcon({ className }: { className?: string }) {
}
export function Footer() {
const [showFeedback, setShowFeedback] = useState(false);
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">
<span>
Made & powered by{" "}
<>
<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">
<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
href={TOGETHER_LINK}
href="https://github.com/nutlope/make-comics"
target="_blank"
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>
</span>
</div>
<div className="flex items-center gap-3">
<Link
href="https://github.com/nutlope/make-comics"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
<Github className="w-3.5 h-3.5" />
</Link>
<Link
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>
<Link
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>
<FeedbackModal isOpen={showFeedback} onClose={() => setShowFeedback(false)} />
</>
);
}
+36 -2
View File
@@ -1,8 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { TOGETHER_LINK } from "@/lib/utils";
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 (
<header className="relative py-8 sm:py-12 md:py-16 lg:py-0">
<div className="relative z-10">
@@ -11,11 +34,12 @@ export function LandingHero() {
href={TOGETHER_LINK}
target="_blank"
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]">
Powered by Together AI
Powered by
</span>
<img src="/poweredby.png" className="h-[18px]"/>
</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]">
@@ -27,6 +51,16 @@ export function LandingHero() {
Describe your scene, choose a style, and let AI render professional
comic panels instantly.
</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>
</header>
+16 -8
View File
@@ -2,10 +2,18 @@
import { useState, useEffect } from "react";
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 { 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";
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">
<img
src="/images/makecomics-logo.png"
src="/images/makecomics-logo.svg"
alt="MakeComics Logo"
className="w-full h-full object-contain"
/>
@@ -88,13 +96,13 @@ export function Navbar() {
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"
>
<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">
{stars}
</span>
</Link>
<SignedOut>
<Show when="signed-out">
<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">
<span className="text-muted-foreground text-xs sm:text-sm hidden sm:inline tracking-tight">
@@ -102,8 +110,8 @@ export function Navbar() {
</span>
</button>
</SignInButton>
</SignedOut>
<SignedIn>
</Show>
<Show when="signed-in">
{isOnStoriesPage ? (
<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">
@@ -123,7 +131,7 @@ export function Navbar() {
</button>
</Link>
)}
</SignedIn>
</Show>
</div>
</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',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
month_grid: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'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 { stories, pages, type Story, type Page } from './schema';
import { eq } from 'drizzle-orm';
import { stories, pages, feedback, type Story, type Page, type Feedback } from './schema';
import { and, eq, gte, isNotNull, sql } from 'drizzle-orm';
import { generateComicSlug } from './slug-generator';
export async function createStory(data: { title: string; description?: string; userId: string; style?: string; usesOwnApiKey?: boolean }): Promise<Story> {
@@ -149,4 +149,18 @@ export async function deletePage(pageId: string): Promise<void> {
export async function deleteStory(storyId: string): Promise<void> {
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;
}
+41 -13
View File
@@ -1,5 +1,12 @@
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({
prompt,
style,
@@ -28,11 +35,37 @@ export function buildComicPrompt({
}
if (isAddPage && previousPages.length > 0) {
const storyHistory = previousPages
.map((page, index) => `Page ${index + 1}: ${page.prompt}`)
.join("\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`;
// Limit previous pages to fit within MAX_SYSTEM_LENGTH
// Start with most recent pages and work backwards
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`;
// 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 = "";
@@ -69,16 +102,11 @@ CHARACTER CONSISTENCY RULES (HIGHEST PRIORITY):
- Apply comic style to body/pose/action but preserve exact facial appearance
- Same character must look identical across all panels they appear in
TEXT AND LETTERING (CRITICAL - MINIMAL TEXT POLICY):
- Keep text to an ABSOLUTE MINIMUM - comic panels should be primarily visual storytelling
- 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
TEXT AND LETTERING (CRITICAL):
- All text in speech bubbles must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled
- Use bold clean comic book lettering, large and easy to read
- 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
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';
// 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
export type Feedback = typeof feedback.$inferSelect;
export type NewFeedback = typeof feedback.$inferInsert;
export type Story = typeof stories.$inferSelect;
export type NewStory = typeof stories.$inferInsert;
+60 -59
View File
@@ -9,78 +9,79 @@
"start": "next start"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.958.0",
"@clerk/nextjs": "^6.36.10",
"@hookform/resolvers": "^3.10.0",
"@neondatabase/serverless": "^1.0.2",
"@next/env": "^16.1.1",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-context-menu": "2.2.4",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-hover-card": "1.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-menubar": "1.1.4",
"@radix-ui/react-navigation-menu": "1.2.3",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-progress": "1.1.1",
"@radix-ui/react-radio-group": "1.2.2",
"@radix-ui/react-scroll-area": "1.2.2",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-separator": "1.1.1",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-switch": "1.1.2",
"@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-toast": "1.2.4",
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@upstash/ratelimit": "^2.0.7",
"@upstash/redis": "^1.36.0",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",
"@aws-sdk/client-s3": "^3.1070.0",
"@clerk/nextjs": "^7.5.3",
"@hookform/resolvers": "^5.4.0",
"@neondatabase/serverless": "^1.1.0",
"@next/env": "^16.2.9",
"@radix-ui/react-accordion": "1.2.14",
"@radix-ui/react-alert-dialog": "1.1.17",
"@radix-ui/react-aspect-ratio": "1.1.10",
"@radix-ui/react-avatar": "1.2.0",
"@radix-ui/react-checkbox": "1.3.5",
"@radix-ui/react-collapsible": "1.1.14",
"@radix-ui/react-context-menu": "2.3.1",
"@radix-ui/react-dialog": "1.1.17",
"@radix-ui/react-dropdown-menu": "2.1.18",
"@radix-ui/react-hover-card": "1.1.17",
"@radix-ui/react-label": "2.1.10",
"@radix-ui/react-menubar": "1.1.18",
"@radix-ui/react-navigation-menu": "1.2.16",
"@radix-ui/react-popover": "1.1.17",
"@radix-ui/react-progress": "1.1.10",
"@radix-ui/react-radio-group": "1.4.1",
"@radix-ui/react-scroll-area": "1.2.12",
"@radix-ui/react-select": "2.3.1",
"@radix-ui/react-separator": "1.1.10",
"@radix-ui/react-slider": "1.4.1",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-switch": "1.3.1",
"@radix-ui/react-tabs": "1.1.15",
"@radix-ui/react-toast": "1.2.17",
"@radix-ui/react-toggle": "1.1.12",
"@radix-ui/react-toggle-group": "1.1.13",
"@radix-ui/react-tooltip": "1.2.10",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"@vercel/analytics": "^2.0.1",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "4.1.0",
"drizzle-orm": "^0.45.1",
"embla-carousel-react": "8.5.1",
"input-otp": "1.4.1",
"jspdf": "^3.0.4",
"lucide-react": "^0.454.0",
"next": "16.1.5",
"next-plausible": "^3.12.5",
"cmdk": "1.1.1",
"date-fns": "4.4.0",
"drizzle-orm": "^0.45.2",
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"jspdf": "^4.2.1",
"lucide-react": "^1.20.0",
"motion": "^12.40.0",
"next": "16.2.9",
"next-plausible": "^4.0.0",
"next-s3-upload": "^0.3.4",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-day-picker": "9.8.0",
"react-dom": "19.2.4",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^3.3.1",
"react": "19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
"react-hook-form": "^7.79.0",
"react-resizable-panels": "^4.11.2",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"together-ai": "^0.33.0",
"together-ai": "^0.40.0",
"use-local-storage-state": "^19.5.0",
"vaul": "^1.1.2",
"zod": "4.2.1"
"zod": "4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@tailwindcss/postcss": "^4.3.1",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.8",
"drizzle-kit": "^0.31.10",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"tailwindcss": "^4.3.1",
"tw-animate-css": "1.4.0",
"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