v3: fixes to database schema and user registration

This commit is contained in:
khannurien
2026-03-23 15:55:45 +00:00
parent fbbbb43258
commit b96879a556
17 changed files with 144 additions and 44 deletions

View File

@@ -17,7 +17,9 @@ function preprocessMentions(text: string): string {
}
// Static components object — defined once at module scope to avoid recreation on every render
const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>["components"] = {
const MARKDOWN_COMPONENTS: React.ComponentProps<
typeof ReactMarkdown
>["components"] = {
a: ({ href, children: linkChildren }) => {
if (href?.startsWith("/users/")) {
return <Link to={href}>{linkChildren}</Link>;

View File

@@ -1,4 +1,10 @@
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";

View File

@@ -63,7 +63,12 @@ function isStringArray(val: unknown): val is string[] {
function isVotesUpdatePayload(
msg: Record<string, unknown>,
): msg is { dumpId: string; voteCount: number; voterId: string; action: "cast" | "remove" } {
): msg is {
dumpId: string;
voteCount: number;
voterId: string;
action: "cast" | "remove";
} {
return typeof msg.dumpId === "string" &&
typeof msg.voteCount === "number" &&
typeof msg.voterId === "string" &&
@@ -164,7 +169,9 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
case "welcome": {
backoff = 500; // reset backoff on successful connect
if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) break;
if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) {
break;
}
setOnlineUsers(msg.users);
setMyVotes(new Set(msg.myVotes));
setUnreadNotificationCount(
@@ -320,7 +327,9 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}
case "notification_created": {
if (!msg.notification || typeof msg.notification !== "object") break;
if (!msg.notification || typeof msg.notification !== "object") {
break;
}
const notification = deserializeNotification(
msg.notification as RawNotification,
);

View File

@@ -148,7 +148,10 @@ export function Dump() {
// Compare against the loaded dump's actual ID.
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
useEffect(() => {
if (!lastCommentEvent || !loadedDumpId || lastCommentEvent.dumpId !== loadedDumpId) return;
if (
!lastCommentEvent || !loadedDumpId ||
lastCommentEvent.dumpId !== loadedDumpId
) return;
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
setComments((prev) => {
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {

View File

@@ -255,9 +255,12 @@ export function Index() {
loadingMore: false,
});
} else {
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
headers: { Authorization: `Bearer ${token}` },
})
fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;

View File

@@ -217,7 +217,9 @@ export function Notifications() {
useEffect(() => {
// 1. Fetch with original read state so unread items are highlighted
// 2. Only after displaying, mark all read on the server
authFetch(`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`)
authFetch(
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("Failed to load");

View File

@@ -10,7 +10,11 @@ import { Link, useParams } from "react-router";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser, hydrateDump } from "../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";

View File

@@ -15,7 +15,11 @@ import type {
PublicUser,
RawPlaylist,
} from "../model.ts";
import { deserializePlaylist, deserializePublicUser, hydratePlaylist } from "../model.ts";
import {
deserializePlaylist,
deserializePublicUser,
hydratePlaylist,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";

View File

@@ -882,7 +882,9 @@ function UpvotedDumpList(
// be called in the same effect body — guaranteeing a single render where the
// dump is always in visibleDumps (with or without fading class). This prevents
// the DOM node from being unmounted/remounted, which would break CSS transitions.
const [votedIds, setVotedIds] = useState(() => new Set(dumps.map((d) => d.id)));
const [votedIds, setVotedIds] = useState(() =>
new Set(dumps.map((d) => d.id))
);
const prevMyVotesRef = useRef<Set<string> | null>(null);
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
@@ -895,8 +897,8 @@ function UpvotedDumpList(
}
const prev = prevMyVotesRef.current;
setVotedIds(new Set(wsMyVotes));
for (const id of prev) { if (!wsMyVotes.has(id)) startFading(id); }
for (const id of wsMyVotes) { if (!prev.has(id)) cancelFading(id); }
for (const id of prev) if (!wsMyVotes.has(id)) startFading(id);
for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id);
prevMyVotesRef.current = new Set(wsMyVotes);
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
@@ -906,7 +908,11 @@ function UpvotedDumpList(
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
setVotedIds((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
setVotedIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
startFading(dumpId);
} else {
setVotedIds((prev) => new Set([...prev, dumpId]));
@@ -914,7 +920,9 @@ function UpvotedDumpList(
}
}, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]);
const visibleDumps = dumps.filter((d) => votedIds.has(d.id) || d.id in fading);
const visibleDumps = dumps.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
return (
<section className="profile-section">

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { API_URL } from "../config/api.ts";
import { API_URL, VALIDATION } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
@@ -109,14 +109,18 @@ export function UserRegister() {
type="text"
placeholder="Username"
required
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
title={`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
disabled={formState.status === "submitting"}
autoFocus
/>
<input
name="password"
type="password"
placeholder="Password"
placeholder={`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
required
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
disabled={formState.status === "submitting"}
/>
<button

View File

@@ -10,7 +10,11 @@ import { Link, useParams } from "react-router";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser, hydrateDump } from "../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
@@ -136,8 +140,8 @@ export function UserUpvoted() {
}
const prev = prevMyVotesRef.current;
setVotedIds(new Set(myVotes));
for (const id of prev) { if (!myVotes.has(id)) startFading(id); }
for (const id of myVotes) { if (!prev.has(id)) cancelFading(id); }
for (const id of prev) if (!myVotes.has(id)) startFading(id);
for (const id of myVotes) if (!prev.has(id)) cancelFading(id);
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId, startFading, cancelFading]);
@@ -174,7 +178,6 @@ export function UserUpvoted() {
}
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
@@ -263,7 +266,9 @@ export function UserUpvoted() {
}
const { profileUser, votes, hasMore, loadingMore } = state;
const visibleDumps = votes.filter((d) => votedIds.has(d.id) || d.id in fading);
const visibleDumps = votes.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
return (
<PageShell>