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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ for the community to fill in some important gaps, however:
- [ ] Improve the `/next` implementation after login
- [ ] Add support for passkeys
- [ ] A basic admin panel
- [ ] User profiles
- [x] User profiles

### License

Expand Down
9 changes: 8 additions & 1 deletion app/(stories)/auth-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ export async function AuthNav() {
return (
<>
<span className="whitespace-nowrap">
{user.username} ({user.karma})
<Link
prefetch={true}
className="hover:underline"
href={`/user/${user.id.replace(/^user_/, "")}`}
>
{user.username}
</Link>{" "}
({user.karma})
</span>
<span className="hidden md:inline px-1">|</span>
<Logout />
Expand Down
16 changes: 14 additions & 2 deletions app/(stories)/item/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ const getStory = async function getStory(idParam: string) {
url: storiesTable.url,
username: storiesTable.username,
points: storiesTable.points,
submitted_by: usersTable.username,
submitted_by: storiesTable.submitted_by,
author_username: usersTable.username,
comments_count: storiesTable.comments_count,
created_at: storiesTable.created_at,
})
Expand Down Expand Up @@ -110,7 +111,18 @@ export default async function ItemPage({

<p className="text-xs text-[#666] md:text-[#828282]">
{story.points} point{story.points > 1 ? "s" : ""} by{" "}
{story.submitted_by ?? story.username}{" "}
{(story.author_username ?? story.username) &&
(story.submitted_by ? (
<Link
prefetch={true}
className="hover:underline"
href={`/user/${story.submitted_by.replace(/^user_/, "")}`}
>
{story.author_username ?? story.username}
</Link>
) : (
<span>{story.author_username ?? story.username}</span>
))}{" "}
<TimeAgo now={now} date={story.created_at} />{" "}
<span aria-hidden={true}>| </span>
<span className="cursor-default" title="Not implemented">
Expand Down
89 changes: 89 additions & 0 deletions app/(stories)/user/[id]/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use server";

import { auth } from "@/app/auth";
import z from "zod";
import { redirect } from "next/navigation";
import { updateProfileRateLimit } from "@/lib/rate-limit";
import { revalidatePath } from "next/cache";
import { db, usersTable } from "@/app/db";
import { sql } from "drizzle-orm";

const UpdateProfileSchema = z.object({
email: z.string().email().optional(),
bio: z.string().optional(),
});

export type UpdateProfileActionData = {
error?:
| {
code: "INTERNAL_ERROR" | "RATE_LIMIT_ERROR";
message: string;
}
| {
code: "VALIDATION_ERROR";
fieldErrors: {
[field: string]: string[];
};
};
};

export async function updateProfileAction(
_prevState: unknown,
formData: FormData
): Promise<UpdateProfileActionData | void> {
const session = await auth();

if (!session?.user?.id) {
redirect("/login");
}

const userId = session.user.id;

const rl = await updateProfileRateLimit.limit(userId);

if (!rl.success) {
return {
error: {
code: "RATE_LIMIT_ERROR",
message: "Too many updates. Try again later",
},
};
}

const email = formData.get("email");
const bio = formData.get("bio");
const input = UpdateProfileSchema.safeParse({
// standardize empty strings to undefined
email: email ? email : undefined,
bio: bio ? bio : undefined,
});

if (!input.success) {
return {
error: {
code: "VALIDATION_ERROR",
fieldErrors: input.error.flatten().fieldErrors,
},
};
}

try {
await db
.update(usersTable)
.set({
email: input.data.email,
bio: input.data.bio,
})
.where(sql`${usersTable.id} = ${userId}`);
} catch (err) {
console.error(err);
return {
error: {
code: "INTERNAL_ERROR",
message: "Failed to create. Please try again later",
},
};
}

revalidatePath(`/user/${userId.replace(/^user_/, "")}`);
}
47 changes: 47 additions & 0 deletions app/(stories)/user/[id]/fields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Link from "next/link";
import type { User } from "./page";
import type { PropsWithChildren } from "react";

export function Username({ user }: { user: User }) {
return (
<tr>
<td>user:</td>
<td>
<Link
className="hover:underline"
href={`/user/${user.id.replace(/^user_/, "")}`}
>
{user.username}
</Link>
</td>
</tr>
);
}

export function Created({ user }: { user: User }) {
const now = Date.now();
return (
<tr>
<td>created:</td>
<td>{user.created_at_formatted}</td>
</tr>
);
}

export function Karma({ user }: { user: User }) {
return (
<tr>
<td>karma:</td>
<td>{user.karma}</td>
</tr>
);
}

export function Table({ children }: PropsWithChildren) {
return (
// negative margin counteracts border-spacing application to edges of table
<table className="border-separate border-spacing-y-[8px] border-spacing-x-[8px] my-[-8px] mx-[-8px]">
<tbody>{children}</tbody>
</table>
);
}
94 changes: 94 additions & 0 deletions app/(stories)/user/[id]/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";

import { useFormState, useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { updateProfileAction, type UpdateProfileActionData } from "./actions";
import type { User } from "./page";
import { Created, Karma, Table, Username } from "./fields";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";

function ErrorMessage({ errors }: { errors: string[] }) {
return (
<div className="mt-1 text-md text-red-500">
{errors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
);
}

function UpdateProfileFormFields({
error,
user,
}: UpdateProfileActionData & { user: User }) {
const { pending } = useFormStatus();
return (
<>
<Table>
<Username user={user} />
<Created user={user} />
<Karma user={user} />
<tr>
<td valign="top">about:</td>
<td valign="top" className="w-full">
<Textarea
className="w-full max-w-lg h-24"
wrap="virtual"
id="bio"
name="bio"
defaultValue={user.bio ?? undefined}
disabled={pending}
/>
{!pending &&
error &&
"fieldErrors" in error &&
error.fieldErrors.bio != null ? (
<ErrorMessage errors={error.fieldErrors.bio} />
) : null}
</td>
</tr>
<tr>
<td>email:</td>
<td>
<Input
className="max-w-lg"
autoCapitalize="off"
id="email"
type="text"
name="email"
defaultValue={user.email ?? undefined}
disabled={pending}
autoComplete="email"
/>
{!pending &&
error &&
"fieldErrors" in error &&
error.fieldErrors.email != null ? (
<ErrorMessage errors={error.fieldErrors.email} />
) : null}
</td>
</tr>
</Table>
<div className="h-4" />
<Button className="p-0 h-8 px-4" disabled={pending}>
{pending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
update
</Button>
{error && "message" in error && !pending ? (
<ErrorMessage errors={[error.message]} />
) : null}
</>
);
}

export function UpdateProfileForm({ user }: { user: User }) {
const [state, formAction] = useFormState(updateProfileAction, {});

return (
<form action={formAction}>
<UpdateProfileFormFields {...state} user={user} />
</form>
);
}
86 changes: 86 additions & 0 deletions app/(stories)/user/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { composeUserId, db, usersTable } from "@/app/db";
import { sql } from "drizzle-orm";
import { notFound } from "next/navigation";
import { auth } from "@/app/auth";
import { Created, Karma, Table, Username } from "./fields";
import { UpdateProfileForm } from "./form";

function formatDate(date: Date) {
return date.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
});
}

async function getUser(id: string, sessionUserId: string | undefined) {
const userId = composeUserId(id);

const user = (
await db
.select({
...{
id: usersTable.id,
username: usersTable.username,
created_at: usersTable.created_at,
karma: usersTable.karma,
bio: usersTable.bio,
},
// protected fields that can be exposed only to the user
// who owns the profile
...(userId && userId === sessionUserId
? {
email: usersTable.email,
}
: {}),
})
.from(usersTable)
.where(sql`${usersTable.id} = ${userId}`)
.limit(1)
)[0];

if (!user) {
return;
}

return {
...user,
created_at_formatted: formatDate(user.created_at),
};
}
export type User = NonNullable<Awaited<ReturnType<typeof getUser>>>;

function Profile({ user }: { user: User }) {
return (
<Table>
<Username user={user} />
<Created user={user} />
<Karma user={user} />
<tr>
<td valign="top">about:</td>
<td valign="top" className="h-24 w-full">
<p className="h-full w-full max-w-[500px]">{user.bio}</p>
</td>
</tr>
</Table>
);
}

export default async function ProfilePage({
params: { id: idParam },
}: {
params: { id: string };
}) {
const session = await auth();
const userId = session?.user?.id;

const user = await getUser(idParam, userId);
if (!user) {
notFound();
}

const isMe = userId && userId === user.id;

// no need to create client-side components unless the user may update their profile
return isMe ? <UpdateProfileForm user={user} /> : <Profile user={user} />;
}
7 changes: 6 additions & 1 deletion app/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const usersTable = pgTable(
username: varchar("username", { length: 256 }).notNull().unique(),
email: varchar("email", { length: 256 }),
karma: integer("karma").notNull().default(0),
bio: text("bio"),
password: varchar("password", { length: 256 }).notNull(),
created_at: timestamp("created_at").notNull().defaultNow(),
updated_at: timestamp("updated_at").notNull().defaultNow(),
Expand All @@ -46,8 +47,12 @@ export const usersTable = pgTable(
})
);

export const composeUserId = (id: string) => {
return `user_${id}`;
};

export const genUserId = () => {
return `user_${nanoid(12)}`;
return composeUserId(nanoid(12));
};

export const storiesTable = pgTable(
Expand Down
Loading