Add inline comments

This commit is contained in:
Fergal Moran
2025-07-04 22:39:08 +01:00
parent 395aec4793
commit 100847853a
14 changed files with 651 additions and 95 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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
View 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();
}
}

View File

@@ -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
View 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>
);
}

View 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>
);
}

View File

@@ -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;
},