Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
type AvatarProps = {
username: string;
avatarUpdatedAt: string | null;
size?: "sm" | "md" | "lg";
className?: string;
};

const SIZE_CLASSES: Record<NonNullable<AvatarProps["size"]>, string> = {
sm: "h-8 w-8 text-sm",
md: "h-10 w-10 text-base",
lg: "h-24 w-24 text-3xl",
};

export default function Avatar({
username,
avatarUpdatedAt,
size = "md",
className = "",
}: AvatarProps) {
const sizeClass = SIZE_CLASSES[size];
const base = `flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-700 font-bold text-white ${sizeClass} ${className}`;

if (!avatarUpdatedAt) {
return <div className={base}>{username.charAt(0).toUpperCase()}</div>;
}

const version = Date.parse(avatarUpdatedAt) || 0;
const src = `/api/avatars/${encodeURIComponent(username)}?v=${version}`;

return (
<div className={base}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`${username} avatar`}
className="h-full w-full object-cover"
/>
</div>
);
}
17 changes: 13 additions & 4 deletions app/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { createPost } from "./actions";
import Avatar from "./Avatar";

export default function Composer({ username }: { username: string }) {
export default function Composer({
username,
avatarUpdatedAt,
}: {
username: string;
avatarUpdatedAt: string | null;
}) {
const router = useRouter();
const formRef = useRef<HTMLFormElement>(null);
const [preview, setPreview] = useState<string | null>(null);
Expand Down Expand Up @@ -34,9 +41,11 @@ export default function Composer({ username }: { username: string }) {
onSubmit={handleSubmit}
className="flex gap-3 border-b border-neutral-800 p-4"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 font-bold text-white">
{username.charAt(0).toUpperCase()}
</div>
<Avatar
username={username}
avatarUpdatedAt={avatarUpdatedAt}
size="md"
/>
<div className="flex-1">
<textarea
name="content"
Expand Down
45 changes: 45 additions & 0 deletions app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,48 @@ export async function createPost(formData: FormData): Promise<void> {

revalidatePath("/");
}

export type AvatarActionState = { error?: string; ok?: boolean } | undefined;

export async function uploadAvatar(
_prev: AvatarActionState,
formData: FormData
): Promise<AvatarActionState> {
const user = await getCurrentUser();
if (!user) redirect("/login");

const file = formData.get("avatar");
if (!(file instanceof File) || file.size === 0) {
return { error: "Choose an image to upload." };
}
if (!file.type.startsWith("image/")) {
return { error: "File must be an image." };
}
if (file.size > 5 * 1024 * 1024) {
return { error: "Image must be smaller than 5MB." };
}

const buffer = Buffer.from(await file.arrayBuffer());

await query(
"UPDATE users SET avatar = $1, avatar_type = $2, avatar_updated_at = now() WHERE id = $3",
[buffer, file.type, user.id]
);

revalidatePath("/");
revalidatePath("/profile");
return { ok: true };
}

export async function removeAvatar(): Promise<void> {
const user = await getCurrentUser();
if (!user) redirect("/login");

await query(
"UPDATE users SET avatar = NULL, avatar_type = NULL, avatar_updated_at = NULL WHERE id = $1",
[user.id]
);

revalidatePath("/");
revalidatePath("/profile");
}
29 changes: 29 additions & 0 deletions app/api/avatars/[username]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest } from "next/server";
import { query } from "@/lib/db";

export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ username: string }> }
) {
const { username } = await params;

const result = await query<{ avatar: Buffer | null; avatar_type: string | null }>(
"SELECT avatar, avatar_type FROM users WHERE username = $1",
[username]
);
const row = result.rows[0];

if (!row?.avatar || !row.avatar_type) {
return new Response("Not found", { status: 404 });
}

return new Response(new Uint8Array(row.avatar), {
headers: {
"Content-Type": row.avatar_type,
// Avatars are addressed by mutable username, so downstream callers
// should cache-bust via ?v=<avatar_updated_at>. Keep the cache long
// once a specific version has been fetched.
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
30 changes: 24 additions & 6 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from "next/link";
import { getCurrentUser } from "@/lib/auth";
import { query } from "@/lib/db";
import Avatar from "./Avatar";
import Composer from "./Composer";
import LogoutButton from "./LogoutButton";

Expand All @@ -10,6 +11,7 @@ type FeedPost = {
has_image: boolean;
created_at: string;
username: string;
avatar_updated_at: string | null;
};

function timeAgo(date: Date) {
Expand All @@ -31,7 +33,8 @@ export default async function Home() {
p.content,
(p.image IS NOT NULL) AS has_image,
p.created_at,
u.username
u.username,
u.avatar_updated_at
FROM posts p
JOIN users u ON u.id = p.user_id
ORDER BY p.created_at DESC
Expand All @@ -44,7 +47,17 @@ export default async function Home() {
<h1 className="text-xl font-bold">🐦 Home</h1>
{user ? (
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-400">@{user.username}</span>
<Link
href="/profile"
className="flex items-center gap-2 rounded-full py-1 pr-3 pl-1 hover:bg-neutral-900"
>
<Avatar
username={user.username}
avatarUpdatedAt={user.avatar_updated_at}
size="sm"
/>
<span className="text-sm text-neutral-400">@{user.username}</span>
</Link>
<LogoutButton />
</div>
) : (
Expand All @@ -66,7 +79,10 @@ export default async function Home() {
</header>

{user ? (
<Composer username={user.username} />
<Composer
username={user.username}
avatarUpdatedAt={user.avatar_updated_at}
/>
) : (
<div className="border-b border-neutral-800 p-4 text-center text-neutral-400">
<Link href="/signup" className="text-sky-500 hover:underline">
Expand All @@ -91,9 +107,11 @@ export default async function Home() {
key={post.id}
className="flex gap-3 border-b border-neutral-800 p-4"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-neutral-700 font-bold text-white">
{post.username.charAt(0).toUpperCase()}
</div>
<Avatar
username={post.username}
avatarUpdatedAt={post.avatar_updated_at}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-semibold">{post.username}</span>
Expand Down
92 changes: 92 additions & 0 deletions app/profile/AvatarForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";

import { useActionState, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { removeAvatar, uploadAvatar } from "../actions";

export default function AvatarForm({ hasAvatar }: { hasAvatar: boolean }) {
const router = useRouter();
const formRef = useRef<HTMLFormElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [state, formAction, pending] = useActionState(uploadAvatar, undefined);
const [removing, setRemoving] = useState(false);

function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
setPreview(file ? URL.createObjectURL(file) : null);
}

async function handleRemove() {
setRemoving(true);
try {
await removeAvatar();
formRef.current?.reset();
setPreview(null);
router.refresh();
} finally {
setRemoving(false);
}
}

return (
<form
ref={formRef}
action={formAction}
className="flex w-full max-w-sm flex-col gap-4 rounded-2xl border border-neutral-800 bg-neutral-950/50 p-5"
>
<h2 className="text-lg font-semibold">Avatar</h2>
<p className="text-sm text-neutral-400">
Upload a photo (PNG, JPG, GIF, WebP). Max 5MB.
</p>

{preview && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={preview}
alt="preview"
className="h-24 w-24 self-center rounded-full border border-neutral-800 object-cover"
/>
)}

<label className="cursor-pointer rounded-full border border-neutral-700 px-4 py-2 text-center text-sm font-semibold hover:bg-neutral-900">
Choose image
<input
type="file"
name="avatar"
accept="image/*"
onChange={onFileChange}
className="hidden"
/>
</label>

{state?.error && (
<p className="text-sm text-red-400" role="alert">
{state.error}
</p>
)}
{state?.ok && !state.error && (
<p className="text-sm text-emerald-400">Avatar updated.</p>
)}

<div className="flex gap-2">
<button
type="submit"
disabled={pending || !preview}
className="flex-1 rounded-full bg-sky-500 px-5 py-2 font-semibold text-white transition hover:bg-sky-600 disabled:opacity-50"
>
{pending ? "Saving…" : "Save avatar"}
</button>
{hasAvatar && (
<button
type="button"
onClick={handleRemove}
disabled={removing}
className="rounded-full border border-neutral-700 px-4 py-2 text-sm hover:bg-neutral-900 disabled:opacity-50"
>
{removing ? "Removing…" : "Remove"}
</button>
)}
</div>
</form>
);
}
40 changes: 40 additions & 0 deletions app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import Avatar from "../Avatar";
import AvatarForm from "./AvatarForm";

export default async function ProfilePage() {
const user = await getCurrentUser();
if (!user) redirect("/login");

return (
<main className="mx-auto min-h-screen max-w-xl border-x border-neutral-800">
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-800 bg-black/70 px-4 py-3 backdrop-blur">
<div className="flex items-center gap-3">
<Link
href="/"
className="text-sm text-neutral-400 hover:text-neutral-200"
>
← Back
</Link>
<h1 className="text-xl font-bold">Profile</h1>
</div>
<span className="text-sm text-neutral-400">@{user.username}</span>
</header>

<section className="flex flex-col items-center gap-6 p-8">
<Avatar
username={user.username}
avatarUpdatedAt={user.avatar_updated_at}
size="lg"
/>
<div className="text-center">
<div className="text-lg font-semibold">{user.username}</div>
<div className="text-sm text-neutral-500">@{user.username}</div>
</div>
<AvatarForm hasAvatar={user.avatar_updated_at !== null} />
</section>
</main>
);
}
16 changes: 12 additions & 4 deletions db/schema.sql
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
-- Twitter clone schema

CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
avatar BYTEA,
avatar_type TEXT,
avatar_updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Backfill columns on databases created before avatars existed.
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar BYTEA;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_type TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_updated_at TIMESTAMPTZ;

CREATE TABLE IF NOT EXISTS posts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
Expand Down
Loading