diff --git a/src/actions/portfolio/albums/createAlbum.ts b/src/actions/portfolio/albums/createAlbum.ts new file mode 100644 index 0000000..11a5fe1 --- /dev/null +++ b/src/actions/portfolio/albums/createAlbum.ts @@ -0,0 +1,25 @@ +"use server" + +import prisma from '@/lib/prisma'; +import { albumSchema } from '@/schemas/portfolio/albumSchema'; + +export async function createAlbum(formData: albumSchema) { + const parsed = albumSchema.safeParse(formData) + + if (!parsed.success) { + console.error("Validation failed", parsed.error) + throw new Error("Invalid input") + } + + const data = parsed.data + + const created = await prisma.portfolioAlbum.create({ + data: { + name: data.name, + slug: data.slug, + description: data.description + }, + }) + + return created +} \ No newline at end of file diff --git a/src/actions/portfolio/albums/updateAlbum.ts b/src/actions/portfolio/albums/updateAlbum.ts new file mode 100644 index 0000000..981173d --- /dev/null +++ b/src/actions/portfolio/albums/updateAlbum.ts @@ -0,0 +1,27 @@ +"use server" + +import prisma from '@/lib/prisma'; +import { albumSchema } from '@/schemas/portfolio/albumSchema'; +import { z } from 'zod/v4'; + +export async function updateAlbum(id: string, rawData: z.infer) { + const parsed = albumSchema.safeParse(rawData) + + if (!parsed.success) { + console.error("Validation failed", parsed.error) + throw new Error("Invalid input") + } + + const data = parsed.data + + const updated = await prisma.portfolioAlbum.update({ + where: { id }, + data: { + name: data.name, + slug: data.slug, + description: data.description + }, + }) + + return updated +} \ No newline at end of file diff --git a/src/actions/portfolio/getJustifiedImages.ts b/src/actions/portfolio/getJustifiedImages.ts new file mode 100644 index 0000000..190189b --- /dev/null +++ b/src/actions/portfolio/getJustifiedImages.ts @@ -0,0 +1,45 @@ +"use server"; + +import prisma from "@/lib/prisma"; + +export async function getJustifiedImages() { + const images = await prisma.portfolioImage.findMany({ + where: { + variants: { + some: { type: "resized" }, + }, + }, + include: { + variants: true, + colors: { include: { color: true } }, + }, + }); + + return images + .map((img) => { + const variant = img.variants.find((v) => v.type === "resized"); + if (!variant || !variant.width || !variant.height) return null; + + const bg = img.colors.find((c) => c.type === "Vibrant")?.color.hex ?? "#e5e7eb"; + + return { + id: img.id, + fileKey: img.fileKey, + altText: img.altText ?? img.name ?? "", + backgroundColor: bg, + width: variant.width, + height: variant.height, + url: variant.url ?? `/api/image/resized/${img.fileKey}.webp`, + }; + }) + .filter(Boolean) as JustifiedInputImage[]; +} + +export interface JustifiedInputImage { + id: string; + url: string; + altText: string; + backgroundColor: string; + width: number; + height: number; +} \ No newline at end of file diff --git a/src/actions/portfolio/images/saveImageLayoutOrder.ts b/src/actions/portfolio/images/saveImageLayoutOrder.ts new file mode 100644 index 0000000..2b719a0 --- /dev/null +++ b/src/actions/portfolio/images/saveImageLayoutOrder.ts @@ -0,0 +1,48 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { z } from 'zod/v4'; + +const ImageLayoutOrderSchema = z.object({ + highlighted: z.array(z.string().cuid()), + featured: z.array(z.string().cuid()), + default: z.array(z.string().cuid()), +}); + +export async function saveImageLayoutOrder(input: unknown) { + const parsed = ImageLayoutOrderSchema.safeParse(input); + if (!parsed.success) { + return { success: false, error: parsed.error.flatten() }; + } + + const { highlighted, featured, default: defaultGroup } = parsed.data; + + const updates = [ + ...highlighted.map((id, i) => ({ + id, + layoutGroup: 'highlighted' as const, + layoutOrder: i, + })), + ...featured.map((id, i) => ({ + id, + layoutGroup: 'featured' as const, + layoutOrder: i, + })), + ...defaultGroup.map((id, i) => ({ + id, + layoutGroup: 'default' as const, + layoutOrder: i, + })), + ]; + + const tx = updates.map(({ id, layoutGroup, layoutOrder }) => + prisma.portfolioImage.update({ + where: { id }, + data: { layoutGroup, layoutOrder }, + }) + ); + + await prisma.$transaction(tx); + + return { success: true }; +} diff --git a/src/app/portfolio/albums/[id]/page.tsx b/src/app/portfolio/albums/[id]/page.tsx new file mode 100644 index 0000000..e4bcb37 --- /dev/null +++ b/src/app/portfolio/albums/[id]/page.tsx @@ -0,0 +1,18 @@ +import EditAlbumForm from "@/components/portfolio/albums/EditAlbumForm"; +import prisma from "@/lib/prisma"; + +export default async function PortfolioAlbumsEditPage({ params }: { params: { id: string } }) { + const { id } = await params; + const album = await prisma.portfolioAlbum.findUnique({ + where: { + id, + } + }) + + return ( +
+

Edit Album

+ {album && } +
+ ); +} \ No newline at end of file diff --git a/src/app/portfolio/albums/new/page.tsx b/src/app/portfolio/albums/new/page.tsx new file mode 100644 index 0000000..75ca5f8 --- /dev/null +++ b/src/app/portfolio/albums/new/page.tsx @@ -0,0 +1,10 @@ +import NewAlbumForm from "@/components/portfolio/albums/NewAlbumForm"; + +export default function PortfolioAlbumsNewPage() { + return ( +
+

New Album

+ +
+ ); +} \ No newline at end of file diff --git a/src/app/portfolio/albums/page.tsx b/src/app/portfolio/albums/page.tsx new file mode 100644 index 0000000..e925adb --- /dev/null +++ b/src/app/portfolio/albums/page.tsx @@ -0,0 +1,20 @@ +import ItemList from "@/components/portfolio/ItemList"; +import prisma from "@/lib/prisma"; +import { PlusCircleIcon } from "lucide-react"; +import Link from "next/link"; + +export default async function PortfolioAlbumsPage() { + const items = await prisma.portfolioAlbum.findMany({}) + + return ( +
+
+

Albums

+ + Add new album + +
+ {items && items.length > 0 ? :

There are no albums yet. Consider adding some!

} +
+ ); +} \ No newline at end of file diff --git a/src/app/portfolio/images/page.tsx b/src/app/portfolio/images/page.tsx index 2183e11..5fdb9b9 100644 --- a/src/app/portfolio/images/page.tsx +++ b/src/app/portfolio/images/page.tsx @@ -1,5 +1,5 @@ +import { AdvancedMosaicGallery } from "@/components/portfolio/images/AdvancedMosaicGallery"; import FilterBar from "@/components/portfolio/images/FilterBar"; -import ImageList from "@/components/portfolio/images/ImageList"; import { Prisma } from "@/generated/prisma"; import prisma from "@/lib/prisma"; import { PlusCircleIcon } from "lucide-react"; @@ -23,7 +23,7 @@ export default async function PortfolioImagesPage({ groupBy = "year", year, album, - } = searchParams ?? {}; + } = await searchParams ?? {}; const groupMode = groupBy === "album" ? "album" : "year"; const groupId = groupMode === "album" ? album ?? "all" : year ?? "all"; @@ -90,7 +90,13 @@ export default async function PortfolioImagesPage({ groupId={groupId} />
- {images && images.length > 0 ? :

There are no images yet. Consider adding some!

} + {images && images.length > 0 ? ({ + ...img, + width: 400, + height: 300, + }))} + /> :

No images found.

}
); diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index ae922a8..24634b8 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -3,6 +3,29 @@ import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import Link from "next/link"; +const portfolioItems = [ + { + title: "Images", + href: "/portfolio/images", + }, + { + title: "Types", + href: "/portfolio/types", + }, + { + title: "Albums", + href: "/portfolio/albums", + }, + { + title: "Categories", + href: "/portfolio/categories", + }, + { + title: "Tags", + href: "/portfolio/tags", + }, +] + export default function TopNav() { return ( @@ -17,42 +40,17 @@ export default function TopNav() { Portfolio
    -
  • - - -
    Images
    -

    -

    - -
    -
  • -
  • - - -
    Types
    -

    -

    - -
    -
  • -
  • - - -
    Categories
    -

    -

    - -
    -
  • -
  • - - -
    Tags
    -

    -

    - -
    -
  • + {portfolioItems.map((item) => ( +
  • + + +
    {item.title}
    +

    +

    + +
    +
  • + ))}
diff --git a/src/components/portfolio/albums/EditAlbumForm.tsx b/src/components/portfolio/albums/EditAlbumForm.tsx new file mode 100644 index 0000000..87d0839 --- /dev/null +++ b/src/components/portfolio/albums/EditAlbumForm.tsx @@ -0,0 +1,92 @@ +"use client" + +import { updateAlbum } from "@/actions/portfolio/albums/updateAlbum"; +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { PortfolioAlbum } from "@/generated/prisma"; +import { albumSchema } from "@/schemas/portfolio/albumSchema"; +import { tagSchema } from "@/schemas/portfolio/tagSchema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod/v4"; + +export default function EditAlbumForm({ album }: { album: PortfolioAlbum }) { + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(albumSchema), + defaultValues: { + name: album.name, + slug: album.slug, + description: album.description || "", + } + }) + + async function onSubmit(values: z.infer) { + try { + const updated = await updateAlbum(album.id, values) + console.log("Album updated:", updated) + toast("Album updated.") + router.push("/portfolio/albums") + } catch (err) { + console.error(err) + toast("Failed to update album.") + } + } + + return ( +
+
+ + {/* String */} + ( + + Name + + + + + + )} + /> + ( + + Slug + + + + + + )} + /> + ( + + Description + +