Skip to content

Commit 256c64b

Browse files
add basic collections
1 parent fde203e commit 256c64b

File tree

15 files changed

+2010
-10
lines changed

15 files changed

+2010
-10
lines changed

app/api/works/route.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createClient } from "@/lib/supabase/server";
2+
import { NextRequest, NextResponse } from "next/server";
3+
4+
export async function GET(request: NextRequest) {
5+
try {
6+
const { searchParams } = new URL(request.url);
7+
const page = parseInt(searchParams.get("page") || "1");
8+
const limit = parseInt(searchParams.get("limit") || "12");
9+
const offset = (page - 1) * limit;
10+
11+
const supabase = await createClient();
12+
13+
// Get the current user
14+
const {
15+
data: { user },
16+
error: authError,
17+
} = await supabase.auth.getUser();
18+
19+
if (authError || !user) {
20+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
21+
}
22+
23+
// Fetch works for the current user
24+
const { data: works, error: worksError } = await supabase
25+
.from("works")
26+
.select("*")
27+
.eq("user_id", user.id)
28+
.order("created_at", { ascending: false })
29+
.range(offset, offset + limit - 1);
30+
31+
if (worksError) {
32+
console.error("Error fetching works:", worksError);
33+
return NextResponse.json(
34+
{ error: "Failed to fetch works" },
35+
{ status: 500 }
36+
);
37+
}
38+
39+
// Get total count for pagination
40+
const { count, error: countError } = await supabase
41+
.from("works")
42+
.select("*", { count: "exact", head: true })
43+
.eq("user_id", user.id);
44+
45+
if (countError) {
46+
console.error("Error counting works:", countError);
47+
return NextResponse.json(
48+
{ error: "Failed to count works" },
49+
{ status: 500 }
50+
);
51+
}
52+
53+
return NextResponse.json({
54+
works: works || [],
55+
total: count || 0,
56+
page,
57+
limit,
58+
});
59+
} catch (error) {
60+
console.error("Unexpected error:", error);
61+
return NextResponse.json(
62+
{ error: "Internal server error" },
63+
{ status: 500 }
64+
);
65+
}
66+
}

app/collections/layout.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Metadata } from "next";
2+
3+
export const metadata: Metadata = {
4+
title: "My Collections - ImgToSVG",
5+
description:
6+
"View and manage your converted SVG works. Download, search, and organize your image to SVG conversions.",
7+
keywords: [
8+
"SVG",
9+
"collections",
10+
"image converter",
11+
"download",
12+
"vector graphics",
13+
],
14+
};
15+
16+
export default function CollectionsLayout({
17+
children,
18+
}: {
19+
children: React.ReactNode;
20+
}) {
21+
return <>{children}</>;
22+
}

app/collections/page.tsx

Lines changed: 278 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,280 @@
1-
const Page = () => {
2-
return <div>Coming soon</div>;
1+
"use client";
2+
3+
import { CollectionsPageSkeleton } from "@/components/Collections/LoadingSkeleton";
4+
import { Pagination } from "@/components/Collections/Pagination";
5+
import { WorkCard } from "@/components/Collections/WorkCard";
6+
import { WorksFilter, applyFilter } from "@/components/Collections/WorksFilter";
7+
import { WorkViewer } from "@/components/Collections/WorkViewer";
8+
import { Button } from "@/components/ui/button";
9+
import { Input } from "@/components/ui/input";
10+
import { Work, WorksResponse } from "@/types/work";
11+
import { motion } from "framer-motion";
12+
import {
13+
FolderOpen,
14+
Image as ImageIcon,
15+
Loader2,
16+
Plus,
17+
Search
18+
} from "lucide-react";
19+
import Link from "next/link";
20+
import { useEffect, useState } from "react";
21+
22+
const CollectionsPage = () => {
23+
const [works, setWorks] = useState<Work[]>([]);
24+
const [loading, setLoading] = useState(true);
25+
const [error, setError] = useState<string | null>(null);
26+
const [currentPage, setCurrentPage] = useState(1);
27+
const [totalPages, setTotalPages] = useState(1);
28+
const [totalCount, setTotalCount] = useState(0);
29+
const [searchTerm, setSearchTerm] = useState("");
30+
const [selectedWork, setSelectedWork] = useState<Work | null>(null);
31+
const [isViewerOpen, setIsViewerOpen] = useState(false);
32+
const [currentFilter, setCurrentFilter] = useState<
33+
"all" | "processed" | "processing" | "recent" | "oldest"
34+
>("all");
35+
36+
const limit = 2;
37+
38+
useEffect(() => {
39+
fetchWorks(currentPage);
40+
}, [currentPage]);
41+
42+
const fetchWorks = async (page: number) => {
43+
try {
44+
setLoading(true);
45+
setError(null);
46+
47+
const response = await fetch(`/api/works?page=${page}&limit=${limit}`);
48+
49+
if (!response.ok) {
50+
if (response.status === 401) {
51+
setError("Please log in to view your collections");
52+
return;
53+
}
54+
throw new Error("Failed to fetch works");
55+
}
56+
57+
const data: WorksResponse = await response.json();
58+
setWorks(data.works);
59+
setTotalCount(data.total);
60+
setTotalPages(Math.ceil(data.total / limit));
61+
} catch (err) {
62+
console.error("Error fetching works:", err);
63+
setError(err instanceof Error ? err.message : "An error occurred");
64+
} finally {
65+
setLoading(false);
66+
}
67+
};
68+
69+
const handleDownload = (work: Work) => {
70+
if (!work.svg_data) return;
71+
72+
const blob = new Blob([work.svg_data], { type: "image/svg+xml" });
73+
const url = URL.createObjectURL(blob);
74+
const a = document.createElement("a");
75+
a.href = url;
76+
a.download = `${work.title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}.svg`;
77+
document.body.appendChild(a);
78+
a.click();
79+
document.body.removeChild(a);
80+
URL.revokeObjectURL(url);
81+
};
82+
83+
const handleView = (work: Work) => {
84+
setSelectedWork(work);
85+
setIsViewerOpen(true);
86+
};
87+
88+
const handleCloseViewer = () => {
89+
setIsViewerOpen(false);
90+
setSelectedWork(null);
91+
};
92+
93+
const filteredWorks = applyFilter(works, currentFilter).filter(
94+
(work) =>
95+
work.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
96+
(work.description &&
97+
work.description.toLowerCase().includes(searchTerm.toLowerCase()))
98+
);
99+
100+
const worksCount = {
101+
all: works.length,
102+
processed: works.filter((work) => work.svg_data).length,
103+
processing: works.filter((work) => !work.svg_data).length,
104+
};
105+
106+
if (loading && works.length === 0) {
107+
return <CollectionsPageSkeleton />;
108+
}
109+
110+
if (error) {
111+
return (
112+
<div className="container mx-auto px-4 py-8">
113+
<div className="flex items-center justify-center min-h-[400px]">
114+
<div className="text-center">
115+
<FolderOpen className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
116+
<h2 className="text-2xl font-semibold mb-2">
117+
Unable to load collections
118+
</h2>
119+
<p className="text-muted-foreground mb-4">{error}</p>
120+
{error.includes("log in") && (
121+
<Link href="/login">
122+
<Button>Go to Login</Button>
123+
</Link>
124+
)}
125+
</div>
126+
</div>
127+
</div>
128+
);
129+
}
130+
131+
return (
132+
<div className="container mx-auto px-4 py-8">
133+
{/* Header */}
134+
<motion.div
135+
initial={{ opacity: 0, y: -20 }}
136+
animate={{ opacity: 1, y: 0 }}
137+
className="mb-8"
138+
>
139+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
140+
<div>
141+
<h1 className="text-3xl font-bold">My Collections</h1>
142+
<p className="text-muted-foreground">
143+
{totalCount > 0
144+
? `${totalCount} work${
145+
totalCount !== 1 ? "s" : ""
146+
} in your collection`
147+
: "Your creative works will appear here"}
148+
</p>
149+
</div>
150+
<Link href="/generate">
151+
<Button className="flex items-center gap-2">
152+
<Plus className="h-4 w-4" />
153+
New Work
154+
</Button>
155+
</Link>
156+
</div>
157+
158+
{/* Search and Controls */}
159+
{works.length > 0 && (
160+
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
161+
<div className="relative flex-1 max-w-md">
162+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
163+
<Input
164+
placeholder="Search your works..."
165+
value={searchTerm}
166+
onChange={(e) => setSearchTerm(e.target.value)}
167+
className="pl-10"
168+
/>
169+
</div>
170+
171+
<div className="flex items-center gap-2">
172+
<WorksFilter
173+
currentFilter={currentFilter}
174+
onFilterChange={setCurrentFilter}
175+
worksCount={worksCount}
176+
/>
177+
</div>
178+
</div>
179+
)}
180+
</motion.div>
181+
182+
{/* Content */}
183+
{works.length === 0 ? (
184+
<motion.div
185+
initial={{ opacity: 0 }}
186+
animate={{ opacity: 1 }}
187+
className="flex items-center justify-center min-h-[400px]"
188+
>
189+
<div className="text-center max-w-md">
190+
<ImageIcon className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
191+
<h2 className="text-2xl font-semibold mb-2">No works yet</h2>
192+
<p className="text-muted-foreground mb-6">
193+
Start creating by converting your first image to SVG
194+
</p>
195+
<Link href="/generate">
196+
<Button className="flex items-center gap-2">
197+
<Plus className="h-4 w-4" />
198+
Create Your First Work
199+
</Button>
200+
</Link>
201+
</div>
202+
</motion.div>
203+
) : filteredWorks.length === 0 ? (
204+
<motion.div
205+
initial={{ opacity: 0 }}
206+
animate={{ opacity: 1 }}
207+
className="flex items-center justify-center min-h-[400px]"
208+
>
209+
<div className="text-center">
210+
<Search className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
211+
<h2 className="text-2xl font-semibold mb-2">No results found</h2>
212+
<p className="text-muted-foreground">
213+
Try adjusting your search terms
214+
</p>
215+
</div>
216+
</motion.div>
217+
) : (
218+
<>
219+
{/* Works Grid */}
220+
<motion.div
221+
initial={{ opacity: 0 }}
222+
animate={{ opacity: 1 }}
223+
transition={{ delay: 0.1 }}
224+
className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
225+
>
226+
{filteredWorks.map((work, index) => (
227+
<motion.div
228+
key={work.id}
229+
initial={{ opacity: 0, y: 20 }}
230+
animate={{ opacity: 1, y: 0 }}
231+
transition={{ delay: index * 0.05 }}
232+
>
233+
<WorkCard
234+
work={work}
235+
onDownload={handleDownload}
236+
onView={handleView}
237+
/>
238+
</motion.div>
239+
))}
240+
</motion.div>
241+
242+
{/* Pagination */}
243+
{totalPages > 1 && (
244+
<motion.div
245+
initial={{ opacity: 0 }}
246+
animate={{ opacity: 1 }}
247+
transition={{ delay: 0.2 }}
248+
className="mt-12 flex justify-center"
249+
>
250+
<Pagination
251+
currentPage={currentPage}
252+
totalPages={totalPages}
253+
onPageChange={setCurrentPage}
254+
/>
255+
</motion.div>
256+
)}
257+
</>
258+
)}
259+
260+
{/* Work Viewer Modal */}
261+
<WorkViewer
262+
work={selectedWork}
263+
isOpen={isViewerOpen}
264+
onClose={handleCloseViewer}
265+
onDownload={handleDownload}
266+
/>
267+
268+
{/* Loading overlay for pagination */}
269+
{loading && works.length > 0 && (
270+
<div className="fixed inset-0 bg-black/20 flex items-center justify-center z-40">
271+
<div className="bg-background p-4 rounded-lg shadow-lg">
272+
<Loader2 className="h-6 w-6 animate-spin" />
273+
</div>
274+
</div>
275+
)}
276+
</div>
277+
);
3278
};
4279

5-
export default Page;
280+
export default CollectionsPage;

0 commit comments

Comments
 (0)