Post deletion, profile link

This commit is contained in:
Christopher
2026-01-22 14:57:42 -08:00
parent 205ec5b5cf
commit 7bc2b1c7e6
8 changed files with 143 additions and 2 deletions
+32
View File
@@ -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' }}>
+6
View File
@@ -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,
+56
View File
@@ -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 }
);
}
}
+4
View File
@@ -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;
+9
View File
@@ -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>
);
+34 -2
View File
@@ -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>
);
+1
View File
@@ -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) => [
+1
View File
@@ -8,6 +8,7 @@ export interface User {
followersCount?: number;
followingCount?: number;
postsCount?: number;
website?: string | null;
createdAt?: string;
movedTo?: string | null;
}