mirror of
https://github.com/fergalmoran/opengifame.git
synced 2025-12-22 09:38:44 +00:00
Add inline comments
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "NODE_ENV=development next dev -p 3002 --turbo & local-ssl-proxy --config ./ssl-proxy.json",
|
||||||
|
"dev:plain": "next dev --turbo",
|
||||||
"dev:ssl": "NODE_ENV=development next dev -p 3002 --turbo & local-ssl-proxy --config ./ssl-proxy.json",
|
"dev:ssl": "NODE_ENV=development next dev -p 3002 --turbo & local-ssl-proxy --config ./ssl-proxy.json",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
|||||||
BIN
public/uploads/1751655970353-598daxi8ps7.png
Normal file
BIN
public/uploads/1751655970353-598daxi8ps7.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
BIN
public/uploads/1751655973928-ypc5n04gq6.png
Normal file
BIN
public/uploads/1751655973928-ypc5n04gq6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
BIN
public/uploads/1751656225977-ldr7sx3ol68.png
Normal file
BIN
public/uploads/1751656225977-ldr7sx3ol68.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
BIN
public/uploads/1751657713879-9rw2aztx9zp.png
Normal file
BIN
public/uploads/1751657713879-9rw2aztx9zp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
65
src/app/api/comments/route.ts
Normal file
65
src/app/api/comments/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAuthSession } from '@/lib/server-auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { comments, users } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerAuthSession();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageId, content } = await request.json();
|
||||||
|
|
||||||
|
if (!imageId || !content?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Image ID and content are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the comment
|
||||||
|
const result = await db
|
||||||
|
.insert(comments)
|
||||||
|
.values({
|
||||||
|
content: content.trim(),
|
||||||
|
imageId,
|
||||||
|
authorId: session.user.id,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: comments.id,
|
||||||
|
content: comments.content,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the author info for the response
|
||||||
|
const authorInfo = await db
|
||||||
|
.select({
|
||||||
|
name: users.name,
|
||||||
|
email: users.email,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, session.user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const commentWithAuthor = {
|
||||||
|
...result[0],
|
||||||
|
authorName: authorInfo[0]?.name || null,
|
||||||
|
authorEmail: authorInfo[0]?.email || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(commentWithAuthor);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating comment:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create comment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/api/images/update-title/route.ts
Normal file
60
src/app/api/images/update-title/route.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAuthSession } from '@/lib/server-auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { images } from '@/lib/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerAuthSession();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageId, title } = await request.json();
|
||||||
|
|
||||||
|
if (!imageId || !title?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Image ID and title are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the title only if the user owns the image
|
||||||
|
const result = await db
|
||||||
|
.update(images)
|
||||||
|
.set({
|
||||||
|
title: title.trim(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(images.id, imageId),
|
||||||
|
eq(images.uploadedBy, session.user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({
|
||||||
|
id: images.id,
|
||||||
|
title: images.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Image not found or you do not have permission to edit it' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, title: result[0].title });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating title:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update title' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { auth } from '@/lib/auth';
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { images, tags, imageTags } from '@/lib/db/schema';
|
import { images, tags, imageTags } from '@/lib/db/schema';
|
||||||
import { writeFile, mkdir } from 'fs/promises';
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
@@ -8,19 +9,28 @@ import { eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
console.log('Upload API called');
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
console.log('Session:', session);
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
|
console.log('No session or user ID found');
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('User ID:', session.user.id);
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
const title = formData.get('title') as string;
|
const title = formData.get('title') as string;
|
||||||
const description = formData.get('description') as string;
|
const description = formData.get('description') as string;
|
||||||
const tagsInput = formData.get('tags') as string;
|
const tagsInput = formData.get('tags') as string;
|
||||||
|
|
||||||
if (!file || !title) {
|
console.log('Processing file:', file?.name);
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
console.log('Form data - Title:', title, 'Description:', description, 'Tags:', tagsInput);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
@@ -43,11 +53,13 @@ export async function POST(request: NextRequest) {
|
|||||||
await writeFile(filepath, Buffer.from(bytes));
|
await writeFile(filepath, Buffer.from(bytes));
|
||||||
|
|
||||||
// Create image record
|
// Create image record
|
||||||
|
console.log('Creating image record in database...');
|
||||||
const imageUrl = `/uploads/${filename}`;
|
const imageUrl = `/uploads/${filename}`;
|
||||||
|
const imageTitle = title || file.name.split('.')[0]; // Use filename without extension as fallback
|
||||||
const [newImage] = await db
|
const [newImage] = await db
|
||||||
.insert(images)
|
.insert(images)
|
||||||
.values({
|
.values({
|
||||||
title,
|
title: imageTitle,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
filename,
|
filename,
|
||||||
originalName: file.name,
|
originalName: file.name,
|
||||||
@@ -58,6 +70,8 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
console.log('Image created with ID:', newImage.id);
|
||||||
|
|
||||||
// Process tags
|
// Process tags
|
||||||
if (tagsInput) {
|
if (tagsInput) {
|
||||||
const tagNames = tagsInput
|
const tagNames = tagsInput
|
||||||
|
|||||||
113
src/app/image/[id]/page.tsx
Normal file
113
src/app/image/[id]/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { images, users, comments } from '@/lib/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Comments } from '@/components/comments';
|
||||||
|
import { EditableTitle } from '@/components/editable-title';
|
||||||
|
|
||||||
|
interface ImagePageProps {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ImagePage({ params }: ImagePageProps) {
|
||||||
|
try {
|
||||||
|
const imageResult = await db
|
||||||
|
.select({
|
||||||
|
id: images.id,
|
||||||
|
title: images.title,
|
||||||
|
description: images.description,
|
||||||
|
url: images.url,
|
||||||
|
originalName: images.originalName,
|
||||||
|
upvotes: images.upvotes,
|
||||||
|
downvotes: images.downvotes,
|
||||||
|
createdAt: images.createdAt,
|
||||||
|
uploadedBy: images.uploadedBy,
|
||||||
|
uploaderName: users.name,
|
||||||
|
uploaderEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(images)
|
||||||
|
.leftJoin(users, eq(images.uploadedBy, users.id))
|
||||||
|
.where(eq(images.id, params.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (imageResult.length === 0) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = imageResult[0];
|
||||||
|
|
||||||
|
// Fetch comments for this image
|
||||||
|
const imageComments = await db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
content: comments.content,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
authorName: users.name,
|
||||||
|
authorEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.leftJoin(users, eq(comments.authorId, users.id))
|
||||||
|
.where(eq(comments.imageId, params.id))
|
||||||
|
.orderBy(desc(comments.createdAt));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Card className="max-w-4xl mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<EditableTitle
|
||||||
|
imageId={params.id}
|
||||||
|
initialTitle={image.title || 'Untitled'}
|
||||||
|
imageOwnerId={image.uploadedBy}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Image Display */}
|
||||||
|
<div className="text-center">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.title || 'Uploaded image'}
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-lg mx-auto"
|
||||||
|
style={{ maxHeight: '80vh' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{image.description && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Description</h3>
|
||||||
|
<p className="text-muted-foreground">{image.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vote counts */}
|
||||||
|
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||||
|
<span>👍 {image.upvotes} upvotes</span>
|
||||||
|
<span>👎 {image.downvotes} downvotes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Section */}
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<Comments
|
||||||
|
imageId={params.id}
|
||||||
|
initialComments={imageComments.map(comment => ({
|
||||||
|
id: comment.id,
|
||||||
|
content: comment.content,
|
||||||
|
authorName: comment.authorName,
|
||||||
|
authorEmail: comment.authorEmail || '',
|
||||||
|
createdAt: comment.createdAt.toISOString(),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading image:', error);
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import Image from 'next/image';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
@@ -14,7 +14,8 @@ export default function UploadPage() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [tags, setTags] = useState('');
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -43,16 +44,40 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addTag = (tagText: string) => {
|
||||||
|
const trimmedTag = tagText.trim().toLowerCase();
|
||||||
|
if (trimmedTag && !tags.includes(trimmedTag)) {
|
||||||
|
setTags([...tags, trimmedTag]);
|
||||||
|
}
|
||||||
|
setTagInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
setTags(tags.filter(tag => tag !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (tagInput.trim()) {
|
||||||
|
addTag(tagInput);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Backspace' && !tagInput && tags.length > 0) {
|
||||||
|
// Remove last tag if input is empty and backspace is pressed
|
||||||
|
removeTag(tags[tags.length - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!file || !title) return;
|
if (!file) return;
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('title', title);
|
formData.append('title', title);
|
||||||
formData.append('description', description);
|
formData.append('description', description);
|
||||||
formData.append('tags', tags);
|
formData.append('tags', tags.join(','));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/images/upload', {
|
const response = await fetch('/api/images/upload', {
|
||||||
@@ -76,21 +101,49 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<Card className="max-w-2xl mx-auto">
|
{!preview ? (
|
||||||
<CardHeader>
|
// Image upload only - before file selection
|
||||||
<CardTitle className="flex items-center space-x-2">
|
<div className="max-w-2xl mx-auto">
|
||||||
<Upload className="h-6 w-6" />
|
<div className="text-center mb-8">
|
||||||
<span>Upload Image</span>
|
<h1 className="text-3xl font-bold mb-2">Share Your Image</h1>
|
||||||
</CardTitle>
|
<p className="text-muted-foreground">Upload an image to get started</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<Card className="border-dashed border-2 border-muted-foreground/25">
|
||||||
<div>
|
<CardContent className="p-12">
|
||||||
<label className="block text-sm font-medium mb-2">
|
<div className="text-center relative">
|
||||||
Choose Image
|
<Upload className="h-16 w-16 mx-auto text-muted-foreground mb-6" />
|
||||||
</label>
|
<h3 className="text-xl font-semibold mb-2">Choose an image to upload</h3>
|
||||||
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6 text-center">
|
<p className="text-muted-foreground mb-6">
|
||||||
{preview ? (
|
Drag and drop your image here, or click to browse
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Full form - after file selection
|
||||||
|
<Card className="max-w-2xl mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<Upload className="h-6 w-6" />
|
||||||
|
<span>Upload Image</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
Image Preview
|
||||||
|
</label>
|
||||||
|
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6 text-center relative">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
@@ -106,81 +159,93 @@ export default function UploadPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
|
setTags([]);
|
||||||
|
setTagInput('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<Upload className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground">
|
<div>
|
||||||
Click to select an image or drag and drop
|
<label htmlFor="title" className="block text-sm font-medium mb-2">
|
||||||
</p>
|
Title
|
||||||
</div>
|
</label>
|
||||||
)}
|
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
id="title"
|
||||||
accept="image/*"
|
value={title}
|
||||||
onChange={handleFileChange}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
className="mt-4"
|
placeholder="Enter image title (optional)"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="title" className="block text-sm font-medium mb-2">
|
<label htmlFor="description" className="block text-sm font-medium mb-2">
|
||||||
Title *
|
Description
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<textarea
|
||||||
id="title"
|
id="description"
|
||||||
value={title}
|
value={description}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter image title"
|
placeholder="Enter image description (optional)"
|
||||||
required
|
className="w-full p-3 border border-input rounded-md bg-background"
|
||||||
/>
|
rows={3}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="description" className="block text-sm font-medium mb-2">
|
<label htmlFor="tags" className="block text-sm font-medium mb-2">
|
||||||
Description
|
Tags
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<div className="space-y-2">
|
||||||
id="description"
|
{/* Tag badges */}
|
||||||
value={description}
|
{tags.length > 0 && (
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<div className="flex flex-wrap gap-2">
|
||||||
placeholder="Enter image description (optional)"
|
{tags.map((tag, index) => (
|
||||||
className="w-full p-3 border border-input rounded-md bg-background"
|
<span
|
||||||
rows={3}
|
key={index}
|
||||||
/>
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border"
|
||||||
</div>
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="ml-1.5 h-3 w-3 rounded-full inline-flex items-center justify-center hover:bg-primary/20"
|
||||||
|
>
|
||||||
|
<X className="h-2 w-2" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tag input */}
|
||||||
|
<Input
|
||||||
|
id="tags"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={handleTagInputKeyDown}
|
||||||
|
placeholder="Type a tag and press Enter"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Press Enter to add tags. Use backspace to remove the last tag.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<Button
|
||||||
<label htmlFor="tags" className="block text-sm font-medium mb-2">
|
type="submit"
|
||||||
Tags
|
className="w-full"
|
||||||
</label>
|
disabled={!file || uploading}
|
||||||
<Input
|
>
|
||||||
id="tags"
|
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||||
value={tags}
|
</Button>
|
||||||
onChange={(e) => setTags(e.target.value)}
|
</form>
|
||||||
placeholder="Enter tags separated by commas (e.g., nature, landscape, sunset)"
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
)}
|
||||||
Separate tags with commas. New tags will be created automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={!file || !title || uploading}
|
|
||||||
>
|
|
||||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
140
src/components/comments.tsx
Normal file
140
src/components/comments.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
authorName: string | null;
|
||||||
|
authorEmail: string;
|
||||||
|
createdAt: string;
|
||||||
|
replies?: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentsProps {
|
||||||
|
imageId: string;
|
||||||
|
initialComments: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Comments({ imageId, initialComments }: CommentsProps) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [comments, setComments] = useState<Comment[]>(initialComments);
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!session || !newComment.trim()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageId,
|
||||||
|
content: newComment.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const addedComment = await response.json();
|
||||||
|
setComments([addedComment, ...comments]);
|
||||||
|
setNewComment('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding comment:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Comments ({comments.length})</h3>
|
||||||
|
|
||||||
|
{/* Add Comment Form - Only for authenticated users */}
|
||||||
|
{session ? (
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmitComment} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !newComment.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Adding...' : 'Add Comment'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
Please sign in to add a comment
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
No comments yet. Be the first to comment!
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<Card key={comment.id}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{comment.authorName || 'Anonymous'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(comment.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm">{comment.content}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/editable-title.tsx
Normal file
98
src/components/editable-title.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
interface EditableTitleProps {
|
||||||
|
imageId: string;
|
||||||
|
initialTitle: string;
|
||||||
|
imageOwnerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableTitle({ imageId, initialTitle, imageOwnerId }: EditableTitleProps) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [title, setTitle] = useState(initialTitle || 'Untitled');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState(title);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = session?.user?.id === imageOwnerId;
|
||||||
|
|
||||||
|
const handleDoubleClick = () => {
|
||||||
|
if (!isOwner) return;
|
||||||
|
setIsEditing(true);
|
||||||
|
setEditValue(title);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editValue.trim() || editValue === title) {
|
||||||
|
setIsEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/update-title', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageId,
|
||||||
|
title: editValue.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setTitle(editValue.trim());
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating title:', error);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditValue(title);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isUpdating}
|
||||||
|
autoFocus
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
placeholder="Enter title..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
isOwner
|
||||||
|
? 'cursor-pointer hover:bg-muted/50 p-2 rounded transition-colors'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
title={isOwner ? 'Double-click to edit title' : undefined}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { NextAuthOptions } from 'next-auth';
|
import { NextAuthOptions } from 'next-auth';
|
||||||
// import { DrizzleAdapter } from '@auth/drizzle-adapter';
|
import { DrizzleAdapter } from '@auth/drizzle-adapter';
|
||||||
import GitHubProvider from 'next-auth/providers/github';
|
import GitHubProvider from 'next-auth/providers/github';
|
||||||
import GoogleProvider from 'next-auth/providers/google';
|
import GoogleProvider from 'next-auth/providers/google';
|
||||||
import FacebookProvider from 'next-auth/providers/facebook';
|
import FacebookProvider from 'next-auth/providers/facebook';
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
// import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
// adapter: DrizzleAdapter(db), // Temporarily disabled until database is set up
|
adapter: DrizzleAdapter(db),
|
||||||
providers: [
|
providers: [
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
name: 'credentials',
|
name: 'credentials',
|
||||||
@@ -46,9 +46,9 @@ export const authOptions: NextAuthOptions = {
|
|||||||
signIn: '/auth/signin',
|
signIn: '/auth/signin',
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
session({ session, token }) {
|
session({ session, user }) {
|
||||||
if (session.user && token.sub) {
|
if (session.user && user) {
|
||||||
session.user.id = token.sub;
|
session.user.id = user.id;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user