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",
|
||||
"private": true,
|
||||
"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",
|
||||
"build": "next build",
|
||||
"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 { auth } from '@/lib/auth';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { images, tags, imageTags } from '@/lib/db/schema';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
@@ -8,19 +9,28 @@ import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
console.log('Upload API called');
|
||||
const session = await getServerSession(authOptions);
|
||||
console.log('Session:', session);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
console.log('No session or user ID found');
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
console.log('User ID:', session.user.id);
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const title = formData.get('title') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const tagsInput = formData.get('tags') as string;
|
||||
|
||||
if (!file || !title) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
console.log('Processing file:', file?.name);
|
||||
console.log('Form data - Title:', title, 'Description:', description, 'Tags:', tagsInput);
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
@@ -43,11 +53,13 @@ export async function POST(request: NextRequest) {
|
||||
await writeFile(filepath, Buffer.from(bytes));
|
||||
|
||||
// Create image record
|
||||
console.log('Creating image record in database...');
|
||||
const imageUrl = `/uploads/${filename}`;
|
||||
const imageTitle = title || file.name.split('.')[0]; // Use filename without extension as fallback
|
||||
const [newImage] = await db
|
||||
.insert(images)
|
||||
.values({
|
||||
title,
|
||||
title: imageTitle,
|
||||
description: description || null,
|
||||
filename,
|
||||
originalName: file.name,
|
||||
@@ -58,6 +70,8 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
console.log('Image created with ID:', newImage.id);
|
||||
|
||||
// Process tags
|
||||
if (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 { useSession } from 'next-auth/react';
|
||||
@@ -14,7 +14,8 @@ export default function UploadPage() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!file || !title) return;
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', title);
|
||||
formData.append('description', description);
|
||||
formData.append('tags', tags);
|
||||
formData.append('tags', tags.join(','));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/images/upload', {
|
||||
@@ -76,6 +101,35 @@ export default function UploadPage() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{!preview ? (
|
||||
// Image upload only - before file selection
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Share Your Image</h1>
|
||||
<p className="text-muted-foreground">Upload an image to get started</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed border-2 border-muted-foreground/25">
|
||||
<CardContent className="p-12">
|
||||
<div className="text-center relative">
|
||||
<Upload className="h-16 w-16 mx-auto text-muted-foreground mb-6" />
|
||||
<h3 className="text-xl font-semibold mb-2">Choose an image to upload</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
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">
|
||||
@@ -87,10 +141,9 @@ export default function UploadPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Choose Image
|
||||
Image Preview
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6 text-center">
|
||||
{preview ? (
|
||||
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6 text-center relative">
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -106,39 +159,25 @@ export default function UploadPage() {
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setTags([]);
|
||||
setTagInput('');
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Upload className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Click to select an image or drag and drop
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="mt-4"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium mb-2">
|
||||
Title *
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter image title"
|
||||
required
|
||||
placeholder="Enter image title (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -160,27 +199,53 @@ export default function UploadPage() {
|
||||
<label htmlFor="tags" className="block text-sm font-medium mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{/* Tag badges */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
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"
|
||||
>
|
||||
{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={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Enter tags separated by commas (e.g., nature, landscape, sunset)"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
placeholder="Type a tag and press Enter"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Separate tags with commas. New tags will be created automatically.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Press Enter to add tags. Use backspace to remove the last tag.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!file || !title || uploading}
|
||||
disabled={!file || uploading}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</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 { DrizzleAdapter } from '@auth/drizzle-adapter';
|
||||
import { DrizzleAdapter } from '@auth/drizzle-adapter';
|
||||
import GitHubProvider from 'next-auth/providers/github';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import FacebookProvider from 'next-auth/providers/facebook';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import bcrypt from 'bcryptjs';
|
||||
// import { db } from '@/lib/db';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
// adapter: DrizzleAdapter(db), // Temporarily disabled until database is set up
|
||||
adapter: DrizzleAdapter(db),
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'credentials',
|
||||
@@ -46,9 +46,9 @@ export const authOptions: NextAuthOptions = {
|
||||
signIn: '/auth/signin',
|
||||
},
|
||||
callbacks: {
|
||||
session({ session, token }) {
|
||||
if (session.user && token.sub) {
|
||||
session.user.id = token.sub;
|
||||
session({ session, user }) {
|
||||
if (session.user && user) {
|
||||
session.user.id = user.id;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user