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

@@ -1,3 +1,4 @@
import { randomBytes, scryptSync } from "node:crypto";
import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
import { import {
type Comment, type Comment,
@@ -17,6 +18,20 @@ db.prepare(
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`, `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
).run(); ).run();
// Create default admin user if no users exist
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
count: number;
};
if (userCount.count === 0) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("admin", salt, 64).toString("hex");
const passwordHash = `${hash}.${salt}`;
db.prepare(
`INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?, 'admin', ?, 1, datetime('now'))`,
).run(crypto.randomUUID(), passwordHash);
console.log("Created default admin user (username: admin, password: admin)");
}
/** /**
* Database Row Types * Database Row Types
*/ */
@@ -70,6 +85,9 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
(typeof obj.comment === "string" || obj.comment === null) && (typeof obj.comment === "string" || obj.comment === null) &&
"user_id" in obj && typeof obj.user_id === "string" && "user_id" in obj && typeof obj.user_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" && "created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
"url" in obj && (typeof obj.url === "string" || obj.url === null) && "url" in obj && (typeof obj.url === "string" || obj.url === null) &&
"rich_content" in obj && "rich_content" in obj &&
(typeof obj.rich_content === "string" || obj.rich_content === null) && (typeof obj.rich_content === "string" || obj.rich_content === null) &&
@@ -92,10 +110,14 @@ export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
"password_hash" in obj && typeof obj.password_hash === "string" && "password_hash" in obj && typeof obj.password_hash === "string" &&
"is_admin" in obj && typeof obj.is_admin === "number" && "is_admin" in obj && typeof obj.is_admin === "number" &&
"created_at" in obj && typeof obj.created_at === "string" && "created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"avatar_mime" in obj && "avatar_mime" in obj &&
(typeof obj.avatar_mime === "string" || obj.avatar_mime === null) && (typeof obj.avatar_mime === "string" || obj.avatar_mime === null) &&
"description" in obj && "description" in obj &&
(typeof obj.description === "string" || obj.description === null); (typeof obj.description === "string" || obj.description === null) &&
"invited_by" in obj &&
(typeof obj.invited_by === "string" || obj.invited_by === null);
} }
/** /**
@@ -108,11 +130,11 @@ export function dumpRowToApi(row: DumpRow): Dump {
kind: row.kind as "url" | "file", kind: row.kind as "url" | "file",
title: row.title, title: row.title,
slug: row.slug ?? undefined, slug: row.slug ?? undefined,
url: row.url ?? undefined,
comment: row.comment ?? undefined, comment: row.comment ?? undefined,
userId: row.user_id, userId: row.user_id,
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
url: row.url ?? undefined,
richContent: row.rich_content richContent: row.rich_content
? (JSON.parse(row.rich_content) as RichContent) ? (JSON.parse(row.rich_content) as RichContent)
: undefined, : undefined,
@@ -201,6 +223,7 @@ export function isCommentRow(
(typeof obj.parent_id === "string" || obj.parent_id === null) && (typeof obj.parent_id === "string" || obj.parent_id === null) &&
typeof obj.body === "string" && typeof obj.body === "string" &&
typeof obj.created_at === "string" && typeof obj.created_at === "string" &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
typeof obj.deleted === "number" && typeof obj.deleted === "number" &&
typeof obj.author_username === "string" && typeof obj.author_username === "string" &&
(typeof obj.author_avatar_mime === "string" || (typeof obj.author_avatar_mime === "string" ||
@@ -241,8 +264,12 @@ export function isPlaylistRow(
return !!obj && typeof obj.id === "string" && return !!obj && typeof obj.id === "string" &&
typeof obj.user_id === "string" && typeof obj.user_id === "string" &&
typeof obj.title === "string" && typeof obj.title === "string" &&
(typeof obj.slug === "string" || obj.slug === null) &&
(typeof obj.description === "string" || obj.description === null) &&
typeof obj.is_public === "number" && typeof obj.is_public === "number" &&
typeof obj.created_at === "string"; typeof obj.created_at === "string" &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
(typeof obj.image_mime === "string" || obj.image_mime === null);
} }
export function playlistRowToApi(row: PlaylistRow): Playlist { export function playlistRowToApi(row: PlaylistRow): Playlist {
@@ -307,7 +334,8 @@ export function isNotificationRow(
typeof obj.type === "string" && typeof obj.type === "string" &&
typeof obj.data === "string" && typeof obj.data === "string" &&
typeof obj.read === "number" && typeof obj.read === "number" &&
typeof obj.created_at === "string"; typeof obj.created_at === "string" &&
(typeof obj.source_key === "string" || obj.source_key === null);
} }
export function notificationRowToApi(row: NotificationRow): Notification { export function notificationRowToApi(row: NotificationRow): Notification {

View File

@@ -89,16 +89,33 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
export function isRegisterUserRequest( export function isRegisterUserRequest(
obj: unknown, obj: unknown,
): obj is RegisterUserRequest { ): obj is RegisterUserRequest {
return validateRegisterUserRequest(obj) === null;
}
/** Returns a human-readable error string, or null if the request is valid. */
export function validateRegisterUserRequest(obj: unknown): string | null {
if ( if (
!obj || typeof obj !== "object" || !obj || typeof obj !== "object" ||
!("username" in obj) || typeof obj.username !== "string" || !("username" in obj) || typeof obj.username !== "string" ||
!("password" in obj) || typeof obj.password !== "string" || !("password" in obj) || typeof obj.password !== "string" ||
!("inviteToken" in obj) || typeof obj.inviteToken !== "string" !("inviteToken" in obj) || typeof obj.inviteToken !== "string"
) return false; ) return "Invalid request";
const { username, password } = obj as RegisterUserRequest; const { username, password } = obj as RegisterUserRequest;
return /^[a-zA-Z0-9_]{1,32}$/.test(username) && if (
password.length >= VALIDATION.PASSWORD_MIN && !new RegExp(
password.length <= VALIDATION.PASSWORD_MAX; `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
)
.test(username)
) {
return `Username must be ${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`;
}
if (password.length < VALIDATION.PASSWORD_MIN) {
return `Password must be at least ${VALIDATION.PASSWORD_MIN} characters`;
}
if (password.length > VALIDATION.PASSWORD_MAX) {
return `Password must be at most ${VALIDATION.PASSWORD_MAX} characters`;
}
return null;
} }
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
@@ -310,7 +327,10 @@ export function isCreatePlaylistRequest(
!("isPublic" in obj) || typeof obj.isPublic !== "boolean" !("isPublic" in obj) || typeof obj.isPublic !== "boolean"
) return false; ) return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false; if (
(o.title as string).length === 0 ||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
) return false;
if ( if (
"description" in o && typeof o.description !== "string" && "description" in o && typeof o.description !== "string" &&
o.description !== null o.description !== null
@@ -329,7 +349,10 @@ export function isUpdatePlaylistRequest(
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
if ("title" in o) { if ("title" in o) {
if (typeof o.title !== "string") return false; if (typeof o.title !== "string") return false;
if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false; if (
(o.title as string).length === 0 ||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
) return false;
} }
if ( if (
"description" in o && typeof o.description !== "string" && "description" in o && typeof o.description !== "string" &&

View File

@@ -4,9 +4,9 @@ import {
APIErrorCode, APIErrorCode,
APIException, APIException,
isLoginUserRequest, isLoginUserRequest,
isRegisterUserRequest,
isUpdateUserRequest, isUpdateUserRequest,
type PaginatedData, type PaginatedData,
validateRegisterUserRequest,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { createJWT, verifyPassword } from "../lib/jwt.ts"; import { createJWT, verifyPassword } from "../lib/jwt.ts";
@@ -36,12 +36,9 @@ const router = new Router({ prefix: "/api/users" });
router.post("/register", async (ctx) => { router.post("/register", async (ctx) => {
const body = await ctx.request.body.json(); const body = await ctx.request.body.json();
if (!isRegisterUserRequest(body)) { const registerError = validateRegisterUserRequest(body);
throw new APIException( if (registerError) {
APIErrorCode.VALIDATION_ERROR, throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
400,
"Invalid request",
);
} }
// Validate invite — throws 404/409 if bad // Validate invite — throws 404/409 if bad

View File

@@ -49,13 +49,13 @@ function titleFromUrl(url: string): string {
} }
const BASE_COLS = const BASE_COLS =
"id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
const SELECT_COLS = `${BASE_COLS}, const SELECT_COLS = `${BASE_COLS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
const SELECT_COLS_ALIASED = const SELECT_COLS_ALIASED =
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," + "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count"; " (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
export async function createUrlDump( export async function createUrlDump(

View File

@@ -20,7 +20,7 @@ import {
// Mirrors dump-service SELECT_COLS_ALIASED — kept local to avoid circular imports // Mirrors dump-service SELECT_COLS_ALIASED — kept local to avoid circular imports
const SELECT_COLS_ALIASED = const SELECT_COLS_ALIASED =
"d.id, d.kind, d.title, d.comment, d.user_id, d.created_at, d.url, d.rich_content, " + "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, " +
"d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," + "d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count"; " (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";

View File

@@ -7,6 +7,7 @@ CREATE TABLE dumps (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT, updated_at TEXT,
url TEXT, url TEXT,
slug TEXT,
rich_content TEXT, rich_content TEXT,
file_name TEXT, file_name TEXT,
file_mime TEXT, file_mime TEXT,
@@ -41,6 +42,7 @@ CREATE TABLE playlists (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
slug TEXT,
description TEXT, description TEXT,
is_public INTEGER NOT NULL DEFAULT 1, is_public INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,

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 // 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 }) => { a: ({ href, children: linkChildren }) => {
if (href?.startsWith("/users/")) { if (href?.startsWith("/users/")) {
return <Link to={href}>{linkChildren}</Link>; 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 { FollowContext, type FollowContextValue } from "./FollowContext.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";

View File

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

View File

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

View File

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

View File

@@ -217,7 +217,9 @@ export function Notifications() {
useEffect(() => { useEffect(() => {
// 1. Fetch with original read state so unread items are highlighted // 1. Fetch with original read state so unread items are highlighted
// 2. Only after displaying, mark all read on the server // 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((r) => r.json())
.then((body) => { .then((body) => {
if (!body.success) throw new Error("Failed to load"); 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 { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts"; import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.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 { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts";

View File

@@ -15,7 +15,11 @@ import type {
PublicUser, PublicUser,
RawPlaylist, RawPlaylist,
} from "../model.ts"; } from "../model.ts";
import { deserializePlaylist, deserializePublicUser, hydratePlaylist } from "../model.ts"; import {
deserializePlaylist,
deserializePublicUser,
hydratePlaylist,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.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 // 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 // dump is always in visibleDumps (with or without fading class). This prevents
// the DOM node from being unmounted/remounted, which would break CSS transitions. // 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); const prevMyVotesRef = useRef<Set<string> | null>(null);
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch. // Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
@@ -895,8 +897,8 @@ function UpvotedDumpList(
} }
const prev = prevMyVotesRef.current; const prev = prevMyVotesRef.current;
setVotedIds(new Set(wsMyVotes)); setVotedIds(new Set(wsMyVotes));
for (const id of prev) { if (!wsMyVotes.has(id)) startFading(id); } 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 wsMyVotes) if (!prev.has(id)) cancelFading(id);
prevMyVotesRef.current = new Set(wsMyVotes); prevMyVotesRef.current = new Set(wsMyVotes);
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]); }, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
@@ -906,7 +908,11 @@ function UpvotedDumpList(
const { dumpId, voterId, action } = lastVoteEvent; const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return; if (voterId !== profileUserId) return;
if (action === "remove") { 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); startFading(dumpId);
} else { } else {
setVotedIds((prev) => new Set([...prev, dumpId])); setVotedIds((prev) => new Set([...prev, dumpId]));
@@ -914,7 +920,9 @@ function UpvotedDumpList(
} }
}, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]); }, [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 ( return (
<section className="profile-section"> <section className="profile-section">

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import type { SubmitEvent } from "react"; import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router"; 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 { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
@@ -109,14 +109,18 @@ export function UserRegister() {
type="text" type="text"
placeholder="Username" placeholder="Username"
required 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"} disabled={formState.status === "submitting"}
autoFocus autoFocus
/> />
<input <input
name="password" name="password"
type="password" type="password"
placeholder="Password" placeholder={`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
required required
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
disabled={formState.status === "submitting"} disabled={formState.status === "submitting"}
/> />
<button <button

View File

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