1st try
This commit is contained in:
parent
597e4de566
commit
ce85d62402
1
my-favorites-app/public/placeholder-book.jpg
Normal file
1
my-favorites-app/public/placeholder-book.jpg
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
122
my-favorites-app/src/app/admin/add/page.tsx
Normal file
122
my-favorites-app/src/app/admin/add/page.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function AddFavoritePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Add New Favorite</h1>
|
||||||
|
<Link href="/admin" passHref>
|
||||||
|
<Button variant="outline">Back to Admin</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<form className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="title" className="text-sm font-medium">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Enter title"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="author" className="text-sm font-medium">
|
||||||
|
Author/Creator
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="author"
|
||||||
|
name="author"
|
||||||
|
type="text"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Enter author or creator"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="type" className="text-sm font-medium">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
name="type"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select type</option>
|
||||||
|
<option value="book">Book</option>
|
||||||
|
<option value="movie">Movie</option>
|
||||||
|
<option value="series">Series</option>
|
||||||
|
<option value="concert">Concert</option>
|
||||||
|
<option value="exhibition">Exhibition</option>
|
||||||
|
<option value="comicbook">Comic Book</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="consumption_date" className="text-sm font-medium">
|
||||||
|
Consumption Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="consumption_date"
|
||||||
|
name="consumption_date"
|
||||||
|
type="date"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<label htmlFor="cover_image" className="text-sm font-medium">
|
||||||
|
Cover Image
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cover_image"
|
||||||
|
name="cover_image"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Upload a cover image or provide a URL below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<label htmlFor="cover_url" className="text-sm font-medium">
|
||||||
|
Cover Image URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cover_url"
|
||||||
|
name="cover_url"
|
||||||
|
type="url"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="submit">Add Favorite</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Note: This form is a placeholder. In the actual implementation, it would submit data to Supabase.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
my-favorites-app/src/app/admin/page.tsx
Normal file
81
my-favorites-app/src/app/admin/page.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { getFavorites } from '@/lib/supabase';
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const favorites = await getFavorites();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your favorite items here. Authentication is handled by Caddy with basic_auth.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Manage Favorites</h2>
|
||||||
|
<Link href="/admin/add" passHref>
|
||||||
|
<Button>Add New Favorite</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="text-xs uppercase bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3">Title</th>
|
||||||
|
<th scope="col" className="px-6 py-3">Author</th>
|
||||||
|
<th scope="col" className="px-6 py-3">Type</th>
|
||||||
|
<th scope="col" className="px-6 py-3">Date</th>
|
||||||
|
<th scope="col" className="px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{favorites.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-4 text-center">
|
||||||
|
No favorites found. Add your first one!
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
favorites.map((favorite) => (
|
||||||
|
<tr key={favorite.id} className="bg-background border-b hover:bg-muted/50">
|
||||||
|
<td className="px-6 py-4 font-medium">{favorite.title}</td>
|
||||||
|
<td className="px-6 py-4">{favorite.author}</td>
|
||||||
|
<td className="px-6 py-4">{favorite.type}</td>
|
||||||
|
<td className="px-6 py-4">{new Date(favorite.consumption_date).toLocaleDateString()}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href={`/admin/edit/${favorite.id}`} passHref>
|
||||||
|
<Button variant="outline" size="sm">Edit</Button>
|
||||||
|
</Link>
|
||||||
|
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive-foreground hover:bg-destructive">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href="/" passHref>
|
||||||
|
<Button variant="outline">Back to Home</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Note: This admin area would be protected by Caddy's basic_auth in production.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,26 +1,56 @@
|
|||||||
@import "tailwindcss";
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #f5f5f0;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
--card: #ffffff;
|
||||||
|
--card-foreground: #171717;
|
||||||
@theme inline {
|
--primary: #3b82f6;
|
||||||
--color-background: var(--background);
|
--primary-foreground: #ffffff;
|
||||||
--color-foreground: var(--foreground);
|
--secondary: #f3f4f6;
|
||||||
--font-sans: var(--font-geist-sans);
|
--secondary-foreground: #111827;
|
||||||
--font-mono: var(--font-geist-mono);
|
--muted: #f3f4f6;
|
||||||
|
--muted-foreground: #6b7280;
|
||||||
|
--accent: #f3f4f6;
|
||||||
|
--accent-foreground: #111827;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--input: #e5e7eb;
|
||||||
|
--ring: #3b82f6;
|
||||||
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #0c0a09;
|
||||||
--foreground: #ededed;
|
--foreground: #fafaf9;
|
||||||
|
--card: #1c1917;
|
||||||
|
--card-foreground: #fafaf9;
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #27272a;
|
||||||
|
--secondary-foreground: #fafafa;
|
||||||
|
--muted: #27272a;
|
||||||
|
--muted-foreground: #a1a1aa;
|
||||||
|
--accent: #27272a;
|
||||||
|
--accent-foreground: #fafafa;
|
||||||
|
--destructive: #7f1d1d;
|
||||||
|
--destructive-foreground: #fef2f2;
|
||||||
|
--border: #27272a;
|
||||||
|
--input: #27272a;
|
||||||
|
--ring: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@layer base {
|
||||||
background: var(--background);
|
* {
|
||||||
color: var(--foreground);
|
@apply border-border;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Header } from "@/components/Header";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "My Favorites",
|
||||||
description: "Generated by create next app",
|
description: "A collection of my favorite books, movies, series, and more",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -25,9 +26,23 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-background`}
|
||||||
>
|
>
|
||||||
{children}
|
<div className="relative flex min-h-screen flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="container py-6 md:py-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer className="border-t py-6 md:py-0">
|
||||||
|
<div className="container flex flex-col items-center justify-between gap-4 md:h-16 md:flex-row">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
© {new Date().getFullYear()} My Favorites. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,103 +1,18 @@
|
|||||||
import Image from "next/image";
|
import { FavoritesGrid } from '@/components/FavoritesGrid';
|
||||||
|
import { getFavorites } from '@/lib/supabase';
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const favorites = await getFavorites();
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
<div className="space-y-6">
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
<div className="space-y-2">
|
||||||
<Image
|
<h1 className="text-3xl font-bold tracking-tight">My Favorites</h1>
|
||||||
className="dark:invert"
|
<p className="text-muted-foreground">
|
||||||
src="/next.svg"
|
A collection of books, movies, series, and more that have made an impression on me.
|
||||||
alt="Next.js logo"
|
</p>
|
||||||
width={180}
|
</div>
|
||||||
height={38}
|
<FavoritesGrid favorites={favorites} />
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
my-favorites-app/src/components/FavoriteCard.tsx
Normal file
48
my-favorites-app/src/components/FavoriteCard.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import Image from 'next/image';
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Favorite } from '@/lib/supabase';
|
||||||
|
import { formatDate, getTypeIcon } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface FavoriteCardProps {
|
||||||
|
favorite: Favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoriteCard({ favorite }: FavoriteCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden h-full flex flex-col">
|
||||||
|
<div className="relative aspect-[3/4] w-full overflow-hidden">
|
||||||
|
{favorite.cover_image ? (
|
||||||
|
<Image
|
||||||
|
src={favorite.cover_image}
|
||||||
|
alt={`Cover for ${favorite.title}`}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
className="object-cover transition-all hover:scale-105"
|
||||||
|
onError={(e) => {
|
||||||
|
// Handle image loading error by setting a fallback
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.src = `/placeholder-${favorite.type}.jpg`;
|
||||||
|
target.onerror = null; // Prevent infinite loop
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||||
|
<span className="text-4xl">{getTypeIcon(favorite.type)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 right-2 bg-black/60 text-white px-2 py-1 rounded text-xs">
|
||||||
|
{favorite.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<CardTitle className="line-clamp-2">{favorite.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0 pb-2 flex-grow">
|
||||||
|
<p className="text-sm text-muted-foreground">{favorite.author}</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="p-4 pt-0 text-xs text-muted-foreground">
|
||||||
|
{formatDate(favorite.consumption_date)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
my-favorites-app/src/components/FavoritesGrid.tsx
Normal file
24
my-favorites-app/src/components/FavoritesGrid.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Favorite } from '@/lib/supabase';
|
||||||
|
import { FavoriteCard } from './FavoriteCard';
|
||||||
|
|
||||||
|
interface FavoritesGridProps {
|
||||||
|
favorites: Favorite[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoritesGrid({ favorites }: FavoritesGridProps) {
|
||||||
|
if (!favorites || favorites.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">No favorites found. Check back later!</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
{favorites.map((favorite) => (
|
||||||
|
<FavoriteCard key={favorite.id} favorite={favorite} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
my-favorites-app/src/components/Header.tsx
Normal file
23
my-favorites-app/src/components/Header.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container flex h-16 items-center">
|
||||||
|
<div className="mr-4 flex">
|
||||||
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
|
<span className="text-xl font-bold">My Favorites</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-end">
|
||||||
|
<nav className="flex items-center space-x-2">
|
||||||
|
<Link href="/admin" passHref>
|
||||||
|
<Button variant="ghost">Admin</Button>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
my-favorites-app/src/components/ui/button.tsx
Normal file
53
my-favorites-app/src/components/ui/button.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
79
my-favorites-app/src/components/ui/card.tsx
Normal file
79
my-favorites-app/src/components/ui/card.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm transition-all hover:shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
105
my-favorites-app/src/lib/supabase.ts
Normal file
105
my-favorites-app/src/lib/supabase.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
export type Favorite = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
consumption_date: string;
|
||||||
|
type: 'book' | 'movie' | 'series' | 'concert' | 'exhibition' | 'comicbook' | 'other';
|
||||||
|
cover_image: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility functions for working with favorites
|
||||||
|
|
||||||
|
export async function getFavorites(): Promise<Favorite[]> {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('favorites')
|
||||||
|
.select('*')
|
||||||
|
.order('consumption_date', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data as Favorite[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching favorites:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFavorite(favorite: Omit<Favorite, 'id' | 'created_at' | 'updated_at'>): Promise<Favorite | null> {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('favorites')
|
||||||
|
.insert(favorite)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data as Favorite;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding favorite:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFavorite(id: string, favorite: Partial<Omit<Favorite, 'id' | 'created_at' | 'updated_at'>>): Promise<Favorite | null> {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('favorites')
|
||||||
|
.update(favorite)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data as Favorite;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating favorite:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFavorite(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('favorites')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting favorite:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCoverImage(file: File, path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.storage
|
||||||
|
.from('covers')
|
||||||
|
.upload(path, file, {
|
||||||
|
cacheControl: '3600',
|
||||||
|
upsert: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Get the public URL for the uploaded file
|
||||||
|
const { data: { publicUrl } } = supabase.storage
|
||||||
|
.from('covers')
|
||||||
|
.getPublicUrl(data.path);
|
||||||
|
|
||||||
|
return publicUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading cover image:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
my-favorites-app/src/lib/utils.ts
Normal file
33
my-favorites-app/src/lib/utils.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTypeIcon(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'book':
|
||||||
|
return '📚';
|
||||||
|
case 'movie':
|
||||||
|
return '🎬';
|
||||||
|
case 'series':
|
||||||
|
return '📺';
|
||||||
|
case 'concert':
|
||||||
|
return '🎵';
|
||||||
|
case 'exhibition':
|
||||||
|
return '🖼️';
|
||||||
|
case 'comicbook':
|
||||||
|
return '💬';
|
||||||
|
default:
|
||||||
|
return '🔖';
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user