Refactor code

This commit is contained in:
2026-02-03 13:12:31 +01:00
parent 8572e22c5d
commit 531bb8750e
23 changed files with 106 additions and 48 deletions

View File

@ -54,12 +54,12 @@ function centroidFromPaletteHexes(
}; };
const fallbackHex = const fallbackHex =
hexByType["Vibrant"] || hexByType.Vibrant ||
hexByType["Muted"] || hexByType.Muted ||
hexByType["DarkVibrant"] || hexByType.DarkVibrant ||
hexByType["DarkMuted"] || hexByType.DarkMuted ||
hexByType["LightVibrant"] || hexByType.LightVibrant ||
hexByType["LightMuted"]; hexByType.LightMuted;
let L = 0, let L = 0,
A = 0, A = 0,
@ -123,7 +123,9 @@ export async function generateArtworkColorsForArtwork(artworkId: string) {
for (const { type, hex } of vibrantHexes) { for (const { type, hex } of vibrantHexes) {
if (!hex) continue; if (!hex) continue;
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); const match = hex.match(/\w\w/g);
if (!match) continue;
const [r, g, b] = match.map((h) => parseInt(h, 16));
const name = generateColorName(hex); const name = generateColorName(hex);
const color = await prisma.color.upsert({ const color = await prisma.color.upsert({

View File

@ -3,7 +3,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { artworkSchema } from "@/schemas/artworks/imageSchema"; import { artworkSchema } from "@/schemas/artworks/imageSchema";
import { normalizeNames, slugify } from "@/utils/artworkHelpers"; import { normalizeNames, slugify } from "@/utils/artworkHelpers";
import { z } from "zod/v4"; import type { z } from "zod/v4";
// Updates an artwork and its tag/category relationships. // Updates an artwork and its tag/category relationships.
export async function updateArtwork( export async function updateArtwork(

View File

@ -2,9 +2,9 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { SignUpResponse } from "@/types/auth";
import { registerFirstUserSchema } from "@/schemas/auth";
import type { RegisterFirstUserInput } from "@/schemas/auth"; import type { RegisterFirstUserInput } from "@/schemas/auth";
import { registerFirstUserSchema } from "@/schemas/auth";
import type { SignUpResponse } from "@/types/auth";
// Registers the very first user and upgrades them to admin. // Registers the very first user and upgrades them to admin.
export async function registerFirstUser(input: RegisterFirstUserInput) { export async function registerFirstUser(input: RegisterFirstUserInput) {

View File

@ -2,9 +2,9 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import type { SessionWithRole } from "@/types/auth"; import type { SessionWithRole } from "@/types/auth";
import type { UsersListRow } from "@/types/users"; import type { UserRole, UsersListRow } from "@/types/users";
import { headers } from "next/headers";
// Returns all users for the admin users table. // Returns all users for the admin users table.
export async function getUsers(): Promise<UsersListRow[]> { export async function getUsers(): Promise<UsersListRow[]> {
@ -28,9 +28,16 @@ export async function getUsers(): Promise<UsersListRow[]> {
}, },
}); });
return rows.map((r) => ({ return rows.map((r) => {
...r, if (r.role !== "admin" && r.role !== "user") {
createdAt: r.createdAt.toISOString(), throw new Error(`Unexpected user role: ${r.role}`);
updatedAt: r.updatedAt.toISOString(), }
}));
return {
...r,
role: r.role as UserRole,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
};
});
} }

View File

@ -2,7 +2,7 @@ import { s3 } from "@/lib/s3";
import type { S3Body } from "@/types/s3"; import type { S3Body } from "@/types/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { Readable } from "stream"; import { Readable } from "node:stream";
function isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> { function isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return !!value && typeof (value as ReadableStream<Uint8Array>).getReader === "function"; return !!value && typeof (value as ReadableStream<Uint8Array>).getReader === "function";

View File

@ -4,7 +4,7 @@ import type { S3Body } from "@/types/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import archiver from "archiver"; import archiver from "archiver";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { Readable } from "stream"; import { Readable } from "node:stream";
// Streams commission request files (single or zip) from S3. // Streams commission request files (single or zip) from S3.
type Mode = "display" | "download" | "bulk"; type Mode = "display" | "download" | "bulk";
@ -17,13 +17,30 @@ function contentDisposition(filename: string, mode: Mode) {
} }
function sanitizeZipEntryName(name: string) { function sanitizeZipEntryName(name: string) {
return name.replace(/[^\w.\- ()\[\]]+/g, "_").slice(0, 180); return name.replace(/[^\w.\- ()[\]]+/g, "_").slice(0, 180);
} }
function isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> { function isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return !!value && typeof (value as ReadableStream<Uint8Array>).getReader === "function"; return !!value && typeof (value as ReadableStream<Uint8Array>).getReader === "function";
} }
function webStreamToAsyncIterable(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
return {
async *[Symbol.asyncIterator]() {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) yield value;
}
} finally {
reader.releaseLock();
}
},
};
}
function toBodyInit(body: S3Body): BodyInit { function toBodyInit(body: S3Body): BodyInit {
if (body instanceof Readable) { if (body instanceof Readable) {
return Readable.toWeb(body) as ReadableStream<Uint8Array>; return Readable.toWeb(body) as ReadableStream<Uint8Array>;
@ -141,9 +158,16 @@ export async function GET(req: NextRequest) {
if (body instanceof Readable) { if (body instanceof Readable) {
archive.append(body, { name: entryName }); archive.append(body, { name: entryName });
} else if (isWebReadableStream(body)) { } else if (isWebReadableStream(body)) {
archive.append(Readable.from(body as AsyncIterable<Uint8Array>), { name: entryName }); archive.append(Readable.from(webStreamToAsyncIterable(body)), { name: entryName });
} else if (body instanceof Blob) {
const stream = body.stream();
archive.append(Readable.from(webStreamToAsyncIterable(stream)), { name: entryName });
} else if (Buffer.isBuffer(body)) {
archive.append(body, { name: entryName });
} else if (body instanceof Uint8Array) {
archive.append(Buffer.from(body), { name: entryName });
} else { } else {
archive.append(body as Buffer, { name: entryName }); throw new Error("Unsupported S3 body type for zip entry");
} }
} }

View File

@ -3,7 +3,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
// Global error UI for the app router segment. // Global error UI for the app router segment.
export default function Error({ export default function ErrorPage({
error, error,
reset, reset,
}: { }: {
@ -27,6 +27,7 @@ export default function Error({
<div className="flex justify-center gap-4 pt-2"> <div className="flex justify-center gap-4 pt-2">
<button <button
type="button"
onClick={reset} onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition" className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition"
> >

View File

@ -21,6 +21,7 @@ export default function GlobalError({
</p> </p>
<button <button
type="button"
onClick={reset} onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition" className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition"
> >

View File

@ -1,9 +1,10 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react";
import { getArtworkColorStats } from "@/actions/colors/getArtworkColorStats"; import { getArtworkColorStats } from "@/actions/colors/getArtworkColorStats";
import { processPendingArtworkColors } from "@/actions/colors/processPendingArtworkColors"; import { processPendingArtworkColors } from "@/actions/colors/processPendingArtworkColors";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
// Admin tool for processing pending artwork color extraction. // Admin tool for processing pending artwork color extraction.
export function ArtworkColorProcessor() { export function ArtworkColorProcessor() {
@ -11,14 +12,14 @@ export function ArtworkColorProcessor() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const refreshStats = async () => { const refreshStats = useCallback(async () => {
const s = await getArtworkColorStats(); const s = await getArtworkColorStats();
setStats(s); setStats(s);
}; }, []);
useEffect(() => { useEffect(() => {
void refreshStats(); void refreshStats();
}, []); }, [refreshStats]);
const run = async () => { const run = async () => {
setLoading(true); setLoading(true);
@ -48,7 +49,7 @@ export function ArtworkColorProcessor() {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button onClick={run} disabled={loading || done}> <Button type="button" onClick={run} disabled={loading || done}>
{done ? "All colors processed" : loading ? "Processing…" : "Process pending colors"} {done ? "All colors processed" : loading ? "Processing…" : "Process pending colors"}
</Button> </Button>

View File

@ -157,6 +157,7 @@ function FilterButton({
}) { }) {
return ( return (
<button <button
type="button"
onClick={onClick} onClick={onClick}
className={`px-3 py-1 rounded text-sm border transition ${active className={`px-3 py-1 rounded text-sm border transition ${active
? "bg-primary text-white border-primary" ? "bg-primary text-white border-primary"

View File

@ -31,7 +31,7 @@ export function MultiSelectFilter(props: {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start"> <PopoverContent className="w-70 p-0" align="start">
<Command> <Command>
<CommandInput placeholder="Search…" /> <CommandInput placeholder="Search…" />
<CommandEmpty>No results.</CommandEmpty> <CommandEmpty>No results.</CommandEmpty>

View File

@ -162,7 +162,9 @@ export default function ArtworkTimelapse({
</div> </div>
{src ? ( {src ? (
<video className="w-full rounded-md border" controls preload="metadata" src={src} /> <video className="w-full rounded-md border" controls preload="metadata" src={src}>
<track kind="captions" label="Captions" srcLang="en" src="" />
</video>
) : null} ) : null}
</div> </div>
); );

View File

@ -11,7 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod/v4"; import type { z } from "zod/v4";
// Form for editing an existing artwork category. // Form for editing an existing artwork category.
export default function EditCategoryForm({ category }: { category: ArtCategory }) { export default function EditCategoryForm({ category }: { category: ArtCategory }) {

View File

@ -10,7 +10,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod/v4"; import type { z } from "zod/v4";
// Form for creating a new artwork category. // Form for creating a new artwork category.
export default function NewCategoryForm() { export default function NewCategoryForm() {

View File

@ -33,7 +33,11 @@ import { toast } from "sonner";
// Admin editor for a single commission request. // Admin editor for a single commission request.
type RequestShape = NonNullable<Awaited<ReturnType<typeof getCommissionRequestById>>>; type RequestShape = NonNullable<Awaited<ReturnType<typeof getCommissionRequestById>>>;
type RequestShapeSerializable = Omit<RequestShape, "createdAt" | "updatedAt" | "files"> & { type RequestShapeSerializable = Omit<
RequestShape,
"createdAt" | "updatedAt" | "files" | "status"
> & {
status: CommissionStatus;
createdAt: string | Date; createdAt: string | Date;
updatedAt: string | Date; updatedAt: string | Date;
files: Array<Omit<RequestShape["files"][number], "createdAt"> & { createdAt: string | Date }>; files: Array<Omit<RequestShape["files"][number], "createdAt"> & { createdAt: string | Date }>;
@ -79,7 +83,9 @@ function bulkUrl(requestId: string) {
export function CommissionRequestEditor({ request }: { request: RequestShapeSerializable }) { export function CommissionRequestEditor({ request }: { request: RequestShapeSerializable }) {
const router = useRouter(); const router = useRouter();
const [status, setStatus] = useState<CommissionStatus>(request.status); const [status, setStatus] = useState<CommissionStatus>(() =>
isCommissionStatus(request.status) ? request.status : "NEW"
);
const [customerName, setCustomerName] = useState(request.customerName); const [customerName, setCustomerName] = useState(request.customerName);
const [customerEmail, setCustomerEmail] = useState(request.customerEmail); const [customerEmail, setCustomerEmail] = useState(request.customerEmail);
const [customerSocials, setCustomerSocials] = useState(request.customerSocials ?? ""); const [customerSocials, setCustomerSocials] = useState(request.customerSocials ?? "");

View File

@ -11,7 +11,7 @@ import { useRouter } from "next/navigation";
import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { type ChangeEvent, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod/v4"; import type * as z from "zod/v4";
type UploadStatus = "empty" | "queued" | "uploading" | "done" | "error"; type UploadStatus = "empty" | "queued" | "uploading" | "done" | "error";

View File

@ -6,7 +6,8 @@ import { useCallback, useEffect, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteUser } from "@/actions/users/deleteUser"; import { deleteUser } from "@/actions/users/deleteUser";
import { getUsers, type UsersListRow } from "@/actions/users/getUsers"; import { getUsers } from "@/actions/users/getUsers";
import type { UsersListRow } from "@/types/users";
// import { resendVerification } from "@/actions/users/resendVerification"; // import { resendVerification } from "@/actions/users/resendVerification";
import { import {

View File

@ -10,6 +10,10 @@ export const COMMISSION_STATUSES = [
export type CommissionStatus = (typeof COMMISSION_STATUSES)[number]; export type CommissionStatus = (typeof COMMISSION_STATUSES)[number];
function isCommissionStatus(value: string): value is CommissionStatus {
return (COMMISSION_STATUSES as readonly string[]).includes(value);
}
export const BOARD_COLUMNS = { export const BOARD_COLUMNS = {
intake: { intake: {
title: "Intake", title: "Intake",
@ -30,15 +34,20 @@ export const BOARD_COLUMNS = {
}, },
} as const; } as const;
const INTAKE_STATUSES = new Set<CommissionStatus>(BOARD_COLUMNS.intake.statuses);
const IN_PROGRESS_STATUSES = new Set<CommissionStatus>(BOARD_COLUMNS.inProgress.statuses);
const COMPLETED_STATUSES = new Set<CommissionStatus>(BOARD_COLUMNS.completed.statuses);
export type BoardColumnId = keyof typeof BOARD_COLUMNS; export type BoardColumnId = keyof typeof BOARD_COLUMNS;
export function columnIdForStatus(status: string): BoardColumnId | null { export function columnIdForStatus(status: string): BoardColumnId | null {
if (BOARD_COLUMNS.intake.statuses.includes(status as any)) return "intake"; if (!isCommissionStatus(status)) return null;
if (BOARD_COLUMNS.inProgress.statuses.includes(status as any)) return "inProgress"; if (INTAKE_STATUSES.has(status)) return "intake";
if (BOARD_COLUMNS.completed.statuses.includes(status as any)) return "completed"; if (IN_PROGRESS_STATUSES.has(status)) return "inProgress";
if (COMPLETED_STATUSES.has(status)) return "completed";
return null; return null;
} }
export function canonicalStatusForColumn(col: BoardColumnId): CommissionStatus { export function canonicalStatusForColumn(col: BoardColumnId): CommissionStatus {
return BOARD_COLUMNS[col].canonicalStatus as CommissionStatus; return BOARD_COLUMNS[col].canonicalStatus;
} }

View File

@ -1,5 +1,5 @@
// src/lib/artworks/query.ts // src/lib/artworks/query.ts
import { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export type ArtworkListParams = { export type ArtworkListParams = {

View File

@ -1,7 +1,7 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
async function isFirstRun() { async function isFirstRun() {
const count = await prisma.user.count(); const count = await prisma.user.count();

View File

@ -2,6 +2,7 @@ export type SessionWithRole =
| { user?: { role?: "admin" | "user"; id?: string } } | { user?: { role?: "admin" | "user"; id?: string } }
| null; | null;
export type SignUpResponse = export type SignUpResponse = {
| { user?: { id?: string } } user?: { id?: string };
| { data?: { user?: { id?: string }; id?: string } }; data?: { user?: { id?: string }; id?: string };
};

View File

@ -1,8 +1,10 @@
export type UserRole = "admin" | "user";
export type UsersListRow = { export type UsersListRow = {
id: string; id: string;
name: string | null; name: string | null;
email: string; email: string;
role: "admin" | "user"; role: UserRole;
emailVerified: boolean; emailVerified: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@ -1,10 +1,10 @@
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import { Readable } from "stream"; import type { Readable } from "stream";
export async function getImageBufferFromS3Key(s3Key: string): Promise<Buffer> { export async function getImageBufferFromS3Key(s3Key: string): Promise<Buffer> {
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: process.env.BUCKET_NAME!, Bucket: process.env.BUCKET_NAME,
Key: s3Key, Key: s3Key,
}); });