Post deletion, profile link
This commit is contained in:
@@ -59,6 +59,7 @@ export default function ProfilePage() {
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
headerUrl: '',
|
||||
website: '',
|
||||
});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -112,6 +113,7 @@ export default function ProfilePage() {
|
||||
bio: user.bio || '',
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
headerUrl: user.headerUrl || '',
|
||||
website: user.website || '',
|
||||
});
|
||||
}
|
||||
}, [user, currentUser]);
|
||||
@@ -353,6 +355,26 @@ export default function ProfilePage() {
|
||||
<span>Joined {formatDate(user.createdAt || new Date().toISOString())}</span>
|
||||
</div>
|
||||
|
||||
{user.website && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '4px',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<Link
|
||||
href={user.website.startsWith('http') ? user.website : `https://${user.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||
>
|
||||
{user.website.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', marginTop: '12px' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('following')}
|
||||
@@ -406,6 +428,16 @@ export default function ProfilePage() {
|
||||
style={{ minHeight: '80px', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Website</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://example.com"
|
||||
value={profileForm.website}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, website: e.target.value })}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
|
||||
@@ -9,6 +9,7 @@ const updateProfileSchema = z.object({
|
||||
bio: z.string().max(160).optional().nullable(),
|
||||
avatarUrl: z.string().url().optional().nullable(),
|
||||
headerUrl: z.string().url().optional().nullable(),
|
||||
website: z.string().url().or(z.string().length(0)).optional().nullable(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
@@ -31,6 +32,7 @@ export async function GET() {
|
||||
displayName: session.user.displayName,
|
||||
avatarUrl: session.user.avatarUrl,
|
||||
bio: session.user.bio,
|
||||
website: session.user.website,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -54,6 +56,7 @@ export async function PATCH(request: Request) {
|
||||
bio?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
headerUrl?: string | null;
|
||||
website?: string | null;
|
||||
updatedAt?: Date;
|
||||
} = {};
|
||||
|
||||
@@ -61,6 +64,7 @@ export async function PATCH(request: Request) {
|
||||
if (data.bio !== undefined) updateData.bio = data.bio === '' ? null : data.bio;
|
||||
if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
|
||||
if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
|
||||
if (data.website !== undefined) updateData.website = data.website === '' ? null : data.website;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -71,6 +75,7 @@ export async function PATCH(request: Request) {
|
||||
avatarUrl: currentUser.avatarUrl,
|
||||
bio: currentUser.bio,
|
||||
headerUrl: currentUser.headerUrl,
|
||||
website: currentUser.website,
|
||||
followersCount: currentUser.followersCount,
|
||||
followingCount: currentUser.followingCount,
|
||||
postsCount: currentUser.postsCount,
|
||||
@@ -94,6 +99,7 @@ export async function PATCH(request: Request) {
|
||||
avatarUrl: updatedUser.avatarUrl,
|
||||
bio: updatedUser.bio,
|
||||
headerUrl: updatedUser.headerUrl,
|
||||
website: updatedUser.website,
|
||||
followersCount: updatedUser.followersCount,
|
||||
followingCount: updatedUser.followingCount,
|
||||
postsCount: updatedUser.postsCount,
|
||||
|
||||
@@ -94,3 +94,59 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { requireAuth } = await import('@/lib/auth');
|
||||
const user = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, id),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (post.userId !== user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 1. If it's a reply, decrement parent's repliesCount
|
||||
if (post.replyToId) {
|
||||
const parentPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, post.replyToId),
|
||||
});
|
||||
if (parentPost && parentPost.repliesCount > 0) {
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: parentPost.repliesCount - 1 })
|
||||
.where(eq(posts.id, post.replyToId));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete the post (cascades to media, likes, notifications)
|
||||
await db.delete(posts).where(eq(posts.id, id));
|
||||
|
||||
// 3. Decrement user's postsCount
|
||||
if (user.postsCount > 0) {
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount - 1 })
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete post error:', error);
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete post' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +373,10 @@ a.btn-primary:visited {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.post-action.delete-action:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* User rows (followers/following) */
|
||||
.user-row {
|
||||
display: flex;
|
||||
|
||||
@@ -128,3 +128,12 @@ export const SettingsIcon = () => (
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrashIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon } from '@/components/Icons';
|
||||
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
|
||||
import { Post } from '@/lib/types';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
onLike?: (id: string, currentLiked: boolean) => void;
|
||||
onRepost?: (id: string, currentReposted: boolean) => void;
|
||||
onComment?: (post: Post) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
isDetail?: boolean;
|
||||
}
|
||||
|
||||
export function PostCard({ post, onLike, onRepost, onComment, isDetail }: PostCardProps) {
|
||||
export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail }: PostCardProps) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [liked, setLiked] = useState(post.isLiked || false);
|
||||
const [reposted, setReposted] = useState(post.isReposted || false);
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Sync state if post changes (e.g. after a re-render from parent)
|
||||
useEffect(() => {
|
||||
@@ -89,6 +93,28 @@ export function PostCard({ post, onLike, onRepost, onComment, isDetail }: PostCa
|
||||
setReporting(false);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (deleting) return;
|
||||
if (!window.confirm('Are you sure you want to delete this post? This action cannot be undone.')) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${post.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
onDelete?.(post.id);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Failed to delete post');
|
||||
}
|
||||
} catch {
|
||||
alert('Failed to delete post');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const postUrl = `/${post.author.handle}/posts/${post.id}`;
|
||||
|
||||
@@ -174,6 +200,12 @@ export function PostCard({ post, onLike, onRepost, onComment, isDetail }: PostCa
|
||||
<FlagIcon />
|
||||
<span>{reporting ? '...' : ''}</span>
|
||||
</button>
|
||||
{currentUser?.id === post.author.id && (
|
||||
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
||||
<TrashIcon />
|
||||
<span>{deleting ? '...' : ''}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -49,6 +49,7 @@ export const users = pgTable('users', {
|
||||
followersCount: integer('followers_count').default(0).notNull(),
|
||||
followingCount: integer('following_count').default(0).notNull(),
|
||||
postsCount: integer('posts_count').default(0).notNull(),
|
||||
website: text('website'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface User {
|
||||
followersCount?: number;
|
||||
followingCount?: number;
|
||||
postsCount?: number;
|
||||
website?: string | null;
|
||||
createdAt?: string;
|
||||
movedTo?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user