Add stories dashboard with API endpoint and navigation updates
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { stories, pages } from "@/lib/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication required" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all stories for the user with their first page
|
||||||
|
const userStories = await db
|
||||||
|
.select({
|
||||||
|
id: stories.id,
|
||||||
|
title: stories.title,
|
||||||
|
slug: stories.slug,
|
||||||
|
createdAt: stories.createdAt,
|
||||||
|
pageCount: pages.pageNumber,
|
||||||
|
coverImage: pages.generatedImageUrl,
|
||||||
|
})
|
||||||
|
.from(stories)
|
||||||
|
.leftJoin(pages, eq(stories.id, pages.storyId))
|
||||||
|
.where(eq(stories.userId, userId))
|
||||||
|
.orderBy(stories.createdAt);
|
||||||
|
|
||||||
|
// Group by story and find the max page number and first page image
|
||||||
|
const storyMap = new Map();
|
||||||
|
|
||||||
|
userStories.forEach((row) => {
|
||||||
|
const storyId = row.id;
|
||||||
|
if (!storyMap.has(storyId)) {
|
||||||
|
storyMap.set(storyId, {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
slug: row.slug,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
pageCount: 0,
|
||||||
|
coverImage: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const story = storyMap.get(storyId);
|
||||||
|
if (row.pageCount && row.pageCount > story.pageCount) {
|
||||||
|
story.pageCount = row.pageCount;
|
||||||
|
}
|
||||||
|
if (row.pageCount === 1 && row.coverImage) {
|
||||||
|
story.coverImage = row.coverImage;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const storiesWithCovers = Array.from(storyMap.values());
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
stories: storiesWithCovers
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user stories:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch stories" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Loader2 } from "lucide-react";
|
||||||
|
import { Navbar } from "@/components/landing/navbar";
|
||||||
|
|
||||||
|
interface Story {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
createdAt: string;
|
||||||
|
pageCount: number;
|
||||||
|
coverImage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoriesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [stories, setStories] = useState<Story[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/stories");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch stories");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setStories(data.stories);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load stories");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex flex-col overflow-hidden relative">
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">Loading your comic library...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex flex-col overflow-hidden relative">
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive mb-4">{error}</p>
|
||||||
|
<Button onClick={fetchStories}>Try Again</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex flex-col overflow-hidden relative">
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main className="flex-1 flex flex-col min-h-[calc(100vh-4rem)]">
|
||||||
|
<div className="w-full px-4 sm:px-6 lg:px-12 xl:px-20 py-4 sm:py-6 relative">
|
||||||
|
<div className="max-w-7xl mx-auto w-full z-10 py-8">
|
||||||
|
{stories.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="inline-flex items-center justify-center w-32 h-40 mb-6 bg-white/5 border-2 border-dashed border-border rounded-sm">
|
||||||
|
<Plus className="w-16 h-16 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">No comics yet</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Create your first comic story to build your library!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
|
{stories.map((story) => (
|
||||||
|
<button
|
||||||
|
key={story.id}
|
||||||
|
onClick={() => router.push(`/editor/${story.slug}`)}
|
||||||
|
className="group relative bg-white aspect-[3/4] p-2 shadow-2xl rounded-sm hover:shadow-indigo/20 hover:shadow-3xl transition-all duration-300 hover:scale-[1.02] hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<div className="w-full h-full bg-neutral-900 border-4 border-black overflow-hidden relative">
|
||||||
|
{story.coverImage ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={story.coverImage}
|
||||||
|
alt={story.title}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105 opacity-80"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{story.pageCount > 1 && (
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-0 left-0 right-0 bottom-0 translate-x-1 translate-y-1 bg-black/20" />
|
||||||
|
{story.pageCount > 2 && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 bottom-0 translate-x-2 translate-y-2 bg-black/10" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2 px-1.5 py-0.5 bg-black/70 text-white text-[9px] font-mono uppercase tracking-widest border border-white/10">
|
||||||
|
{story.pageCount}p
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-2 text-left">
|
||||||
|
<h3 className="font-display text-xs text-white leading-tight line-clamp-2 mb-0.5">
|
||||||
|
{story.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[9px] text-white/50 font-mono uppercase tracking-wider">
|
||||||
|
{new Date(story.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-white/40 mx-auto mb-2" />
|
||||||
|
<p className="text-[9px] text-white/50 font-mono uppercase tracking-wider">Generating...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export function EditorToolbar({ title, onContinueStory, onInfoClick }: EditorToo
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/stories")}
|
||||||
className="hover:bg-secondary text-muted-foreground hover:text-white flex-shrink-0"
|
className="hover:bg-secondary text-muted-foreground hover:text-white flex-shrink-0"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function CreateButton({
|
|||||||
const [loadingStep, setLoadingStep] = useState(0);
|
const [loadingStep, setLoadingStep] = useState(0);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { uploadToS3 } = useS3Upload();
|
const { uploadToS3 } = useS3Upload();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn, isLoaded } = useAuth();
|
||||||
const [hasApiKey, setHasApiKey] = useState(false);
|
const [hasApiKey, setHasApiKey] = useState(false);
|
||||||
|
|
||||||
// Check if user has their own API key set
|
// Check if user has their own API key set
|
||||||
@@ -131,7 +131,9 @@ export function CreateButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
{isSignedIn ? (
|
{!isLoaded ? (
|
||||||
|
<div className="h-10" />
|
||||||
|
) : isSignedIn ? (
|
||||||
<div className="flex items-center justify-between gap-3 w-full">
|
<div className="flex items-center justify-between gap-3 w-full">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Github, Key, User } from "lucide-react";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Github, Key, BookOpen, User, Plus } from "lucide-react";
|
||||||
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 {
|
import {
|
||||||
@@ -14,16 +15,19 @@ import {
|
|||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [showApiModal, setShowApiModal] = useState(false);
|
const [showApiModal, setShowApiModal] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const handleApiKeySubmit = (key: string) => {
|
const handleApiKeySubmit = (key: string) => {
|
||||||
localStorage.setItem("together_api_key", key);
|
localStorage.setItem("together_api_key", key);
|
||||||
setShowApiModal(false);
|
setShowApiModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOnStoriesPage = pathname === "/stories";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="w-full h-14 sm:h-16 border-b border-border/50 flex items-center justify-between px-4 sm:px-6 lg:px-8 z-50 bg-background/80 backdrop-blur-md">
|
<nav className="w-full h-14 sm:h-16 border-b border-border/50 flex items-center justify-between px-4 sm:px-6 lg:px-8 z-50 bg-background/80 backdrop-blur-md">
|
||||||
<div className="flex items-center gap-1">
|
<Link href="/" className="flex items-center gap-1 hover:opacity-80 transition-opacity">
|
||||||
<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.png"
|
||||||
@@ -34,7 +38,7 @@ export function Navbar() {
|
|||||||
<span className="text-white font-heading tracking-[0.005em] text-lg sm:text-xl">
|
<span className="text-white font-heading tracking-[0.005em] text-lg sm:text-xl">
|
||||||
MakeComics
|
MakeComics
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -69,12 +73,25 @@ export function Navbar() {
|
|||||||
</SignInButton>
|
</SignInButton>
|
||||||
</SignedOut>
|
</SignedOut>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
|
{isOnStoriesPage ? (
|
||||||
|
<Link href="/">
|
||||||
|
<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">
|
||||||
|
<Plus 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 tracking-tight">
|
||||||
|
Create New
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/stories">
|
||||||
<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">
|
||||||
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<User 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 tracking-tight">
|
<span className="text-muted-foreground text-xs sm:text-sm hidden sm:inline tracking-tight">
|
||||||
My Stories
|
My Stories
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user