Add CRUD for albums
This commit is contained in:
16
src/actions/albums/createAlbum.ts
Normal file
16
src/actions/albums/createAlbum.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { albumSchema } from "@/schemas/albums/albumSchema";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export async function createAlbum(values: z.infer<typeof albumSchema>) {
|
||||||
|
return await prisma.album.create({
|
||||||
|
data: {
|
||||||
|
name: values.name,
|
||||||
|
slug: values.slug,
|
||||||
|
description: values.description,
|
||||||
|
galleryId: values.galleryId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
7
src/actions/albums/deleteAlbum.ts
Normal file
7
src/actions/albums/deleteAlbum.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function deleteAlbum(id: string) {
|
||||||
|
await prisma.gallery.delete({ where: { id } });
|
||||||
|
}
|
22
src/actions/albums/updateAlbum.ts
Normal file
22
src/actions/albums/updateAlbum.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { albumSchema } from "@/schemas/albums/albumSchema";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export async function updateAlbum(
|
||||||
|
values: z.infer<typeof albumSchema>,
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
|
return await prisma.album.update({
|
||||||
|
where: {
|
||||||
|
id: id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: values.name,
|
||||||
|
slug: values.slug,
|
||||||
|
description: values.description,
|
||||||
|
galleryId: values.galleryId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
21
src/app/albums/edit/[id]/page.tsx
Normal file
21
src/app/albums/edit/[id]/page.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import EditAlbumForm from "@/components/albums/edit/EditAlbumForm";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function AlbumsEditPage({ params }: { params: { id: string } }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const album = await prisma.album.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const galleries = await prisma.gallery.findMany({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Edit album</h1>
|
||||||
|
{album ? <EditAlbumForm album={album} galleries={galleries} /> : 'Album not found...'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
13
src/app/albums/new/page.tsx
Normal file
13
src/app/albums/new/page.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import CreateAlbumForm from "@/components/albums/new/CreateAlbumForm";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function AlbumsNewPage() {
|
||||||
|
const galleries = await prisma.gallery.findMany({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">New album</h1>
|
||||||
|
<CreateAlbumForm galleries={galleries} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
25
src/app/albums/page.tsx
Normal file
25
src/app/albums/page.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import ListAlbums from "@/components/albums/list/ListAlbums";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { PlusCircleIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function AlbumsPage() {
|
||||||
|
const albums = await prisma.album.findMany(
|
||||||
|
{
|
||||||
|
include: { gallery: true },
|
||||||
|
orderBy: { createdAt: "asc" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-4 justify-between">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Albums</h1>
|
||||||
|
<Link href="/albums/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
||||||
|
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new Album
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{albums.length > 0 ? <ListAlbums albums={albums} /> : <p className="text-muted-foreground italic">No albums found.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
120
src/components/albums/edit/EditAlbumForm.tsx
Normal file
120
src/components/albums/edit/EditAlbumForm.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { updateAlbum } from "@/actions/albums/updateAlbum";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Album, Gallery } from "@/generated/prisma";
|
||||||
|
import { albumSchema } from "@/schemas/albums/albumSchema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export default function EditAlbumForm({ album, galleries }: { album: Album, galleries: Gallery[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<z.infer<typeof albumSchema>>({
|
||||||
|
resolver: zodResolver(albumSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: album.name,
|
||||||
|
slug: album.slug,
|
||||||
|
description: album.description || "",
|
||||||
|
galleryId: album.galleryId || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof albumSchema>) {
|
||||||
|
var updatedAlbum = await updateAlbum(values, album.id)
|
||||||
|
if (updatedAlbum) {
|
||||||
|
toast.success("Album updated")
|
||||||
|
router.push(`/albums`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Album name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Album name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your public display name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Album slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Album slug" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Will be used for the navigation.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Album description (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Album description" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Description of the Album.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="galleryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Gallery</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a gallery" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{galleries.map((gallery) => (
|
||||||
|
<SelectItem key={gallery.id} value={gallery.id}>
|
||||||
|
{gallery.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
42
src/components/albums/list/ListAlbums.tsx
Normal file
42
src/components/albums/list/ListAlbums.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Album, Gallery } from "@/generated/prisma";
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type AlbumWithGallery = Album & {
|
||||||
|
gallery: Gallery | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ListAlbums({ albums }: { albums: AlbumWithGallery[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{albums.map((album) => (
|
||||||
|
<Link href={`/albums/edit/${album.id}`} key={album.id}>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base truncate">{album.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{album.description && <p className="text-sm text-muted-foreground">{album.description}</p>}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{album.gallery ? (
|
||||||
|
<>Gallery: {album.gallery.name}</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center text-destructive">
|
||||||
|
<TriangleAlert className="mr-2 h-4 w-4" />
|
||||||
|
No gallery
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Total images in this album: <span className="font-semibold">0</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
118
src/components/albums/new/CreateAlbumForm.tsx
Normal file
118
src/components/albums/new/CreateAlbumForm.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createAlbum } from "@/actions/albums/createAlbum";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Gallery } from "@/generated/prisma";
|
||||||
|
import { albumSchema } from "@/schemas/albums/albumSchema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export default function CreateAlbumForm({ galleries }: { galleries: Gallery[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<z.infer<typeof albumSchema>>({
|
||||||
|
resolver: zodResolver(albumSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
description: "",
|
||||||
|
galleryId: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof albumSchema>) {
|
||||||
|
var album = await createAlbum(values)
|
||||||
|
if (album) {
|
||||||
|
toast.success("Album created")
|
||||||
|
router.push(`/albums`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Album name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Album name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your public display name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Album slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Album slug" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Will be used for the navigation.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Album description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Album description" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Description of the album.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="galleryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Gallery</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a gallery" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{galleries.map((gallery) => (
|
||||||
|
<SelectItem key={gallery.id} value={gallery.id}>
|
||||||
|
{gallery.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
@ -81,7 +81,10 @@ export default function CreateGalleryForm() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
@ -17,6 +17,11 @@ export default function TopNav() {
|
|||||||
<Link href="/galleries">Galleries</Link>
|
<Link href="/galleries">Galleries</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
|
<Link href="/albums">Albums</Link>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</NavigationMenuItem>
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
</NavigationMenu>
|
</NavigationMenu>
|
||||||
);
|
);
|
||||||
|
9
src/schemas/albums/albumSchema.ts
Normal file
9
src/schemas/albums/albumSchema.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const albumSchema = z.object({
|
||||||
|
name: z.string().min(3, "Name is required. Min 3 characters."),
|
||||||
|
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
||||||
|
galleryId: z.string().min(1, "Please select a gallery"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
Reference in New Issue
Block a user