chore: fix lint errors — auto-format, isNaN, unused imports, button type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,8 @@
|
||||
"noLabelWithoutControl": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
|
||||
@@ -25,7 +25,7 @@ const args = Object.fromEntries(
|
||||
}),
|
||||
);
|
||||
|
||||
const tier = args["tier"] ? Number(args["tier"]) : 1;
|
||||
const tier = args.tier ? Number(args.tier) : 1;
|
||||
const dryRun = args["dry-run"] === "true";
|
||||
|
||||
async function listActiveManufacturers(targetTier: number) {
|
||||
|
||||
@@ -34,7 +34,7 @@ const args = Object.fromEntries(
|
||||
}),
|
||||
);
|
||||
|
||||
const manufacturerSlug = args["manufacturer"];
|
||||
const manufacturerSlug = args.manufacturer;
|
||||
const dryRun = args["dry-run"] === "true";
|
||||
|
||||
if (!manufacturerSlug) {
|
||||
|
||||
@@ -22,7 +22,11 @@ const [user] = await db
|
||||
.update(users)
|
||||
.set({ isAdmin: !revoke })
|
||||
.where(eq(users.logtoSub, sub))
|
||||
.returning({ id: users.id, logtoSub: users.logtoSub, isAdmin: users.isAdmin });
|
||||
.returning({
|
||||
id: users.id,
|
||||
logtoSub: users.logtoSub,
|
||||
isAdmin: users.isAdmin,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error(`User not found with logto_sub: ${sub}`);
|
||||
@@ -30,4 +34,6 @@ if (!user) {
|
||||
}
|
||||
|
||||
const action = revoke ? "Revoked admin from" : "Granted admin to";
|
||||
console.log(`${action} user ${user.id} (${user.logtoSub}) — isAdmin: ${user.isAdmin}`);
|
||||
console.log(
|
||||
`${action} user ${user.id} (${user.logtoSub}) — isAdmin: ${user.isAdmin}`,
|
||||
);
|
||||
|
||||
@@ -244,7 +244,6 @@ export function ComparisonTable({
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||
<li key={i} className="text-xs text-gray-700">
|
||||
{item}
|
||||
</li>
|
||||
@@ -263,7 +262,6 @@ export function ComparisonTable({
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||
<li key={i} className="text-xs text-gray-700">
|
||||
{item}
|
||||
</li>
|
||||
|
||||
@@ -61,9 +61,7 @@ export function ItemCard({
|
||||
const duplicateItem = useDuplicateItem();
|
||||
|
||||
const displayName =
|
||||
brand && name.startsWith(`${brand} `)
|
||||
? name.slice(brand.length + 1)
|
||||
: name;
|
||||
brand && name.startsWith(`${brand} `) ? name.slice(brand.length + 1) : name;
|
||||
|
||||
const handleClick =
|
||||
linkTo === null
|
||||
|
||||
@@ -67,9 +67,7 @@ export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["admin-global-items", query ?? "", tagNames ?? []],
|
||||
queryFn: ({ pageParam = 0 }) =>
|
||||
apiGet<AdminGlobalItemPage>(
|
||||
`/api/admin/items?offset=${pageParam}&${qs}`,
|
||||
),
|
||||
apiGet<AdminGlobalItemPage>(`/api/admin/items?offset=${pageParam}&${qs}`),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||
initialPageParam: 0,
|
||||
@@ -79,8 +77,7 @@ export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
|
||||
export function useAdminGlobalItem(id: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-global-item", id],
|
||||
queryFn: () =>
|
||||
apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
|
||||
queryFn: () => apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
|
||||
enabled: id != null,
|
||||
retry: (count, error) =>
|
||||
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||
@@ -90,13 +87,8 @@ export function useAdminGlobalItem(id: number | null) {
|
||||
export function useUpdateAdminGlobalItem() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number;
|
||||
data: UpdateGlobalItemPayload;
|
||||
}) => apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateGlobalItemPayload }) =>
|
||||
apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
|
||||
onSuccess: (_result, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-global-items"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-global-item", id] });
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiDelete, apiGet, apiPost } from "../lib/api";
|
||||
|
||||
interface AuthState {
|
||||
user: { id: string; email?: string; createdAt?: string; isAdmin?: boolean } | null;
|
||||
user: {
|
||||
id: string;
|
||||
email?: string;
|
||||
createdAt?: string;
|
||||
isAdmin?: boolean;
|
||||
} | null;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ import { Route as UsersUserIdRouteImport } from './routes/users/$userId'
|
||||
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
|
||||
import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId'
|
||||
import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId'
|
||||
import { Route as AdminTagsRouteImport } from './routes/admin/tags'
|
||||
import { Route as AdminItemsRouteImport } from './routes/admin/items'
|
||||
import { Route as ThreadsThreadIdIndexRouteImport } from './routes/threads/$threadId/index'
|
||||
import { Route as AdminTagsTagIdRouteImport } from './routes/admin/tags.$tagId'
|
||||
import { Route as AdminItemsItemIdRouteImport } from './routes/admin/items.$itemId'
|
||||
import { Route as ThreadsThreadIdCandidatesCandidateIdRouteImport } from './routes/threads/$threadId/candidates/$candidateId'
|
||||
|
||||
@@ -92,6 +94,11 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({
|
||||
path: '/global-items/$globalItemId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminTagsRoute = AdminTagsRouteImport.update({
|
||||
id: '/tags',
|
||||
path: '/tags',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminItemsRoute = AdminItemsRouteImport.update({
|
||||
id: '/items',
|
||||
path: '/items',
|
||||
@@ -102,6 +109,11 @@ const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({
|
||||
path: '/threads/$threadId/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminTagsTagIdRoute = AdminTagsTagIdRouteImport.update({
|
||||
id: '/$tagId',
|
||||
path: '/$tagId',
|
||||
getParentRoute: () => AdminTagsRoute,
|
||||
} as any)
|
||||
const AdminItemsItemIdRoute = AdminItemsItemIdRouteImport.update({
|
||||
id: '/$itemId',
|
||||
path: '/$itemId',
|
||||
@@ -121,6 +133,7 @@ export interface FileRoutesByFullPath {
|
||||
'/profile': typeof ProfileRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/items': typeof AdminItemsRouteWithChildren
|
||||
'/admin/tags': typeof AdminTagsRouteWithChildren
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/items/$itemId': typeof ItemsItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
@@ -130,6 +143,7 @@ export interface FileRoutesByFullPath {
|
||||
'/global-items/': typeof GlobalItemsIndexRoute
|
||||
'/setups/': typeof SetupsIndexRoute
|
||||
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
|
||||
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
|
||||
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
|
||||
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||
}
|
||||
@@ -139,6 +153,7 @@ export interface FileRoutesByTo {
|
||||
'/profile': typeof ProfileRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/items': typeof AdminItemsRouteWithChildren
|
||||
'/admin/tags': typeof AdminTagsRouteWithChildren
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/items/$itemId': typeof ItemsItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
@@ -148,6 +163,7 @@ export interface FileRoutesByTo {
|
||||
'/global-items': typeof GlobalItemsIndexRoute
|
||||
'/setups': typeof SetupsIndexRoute
|
||||
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
|
||||
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdIndexRoute
|
||||
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||
}
|
||||
@@ -159,6 +175,7 @@ export interface FileRoutesById {
|
||||
'/profile': typeof ProfileRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/items': typeof AdminItemsRouteWithChildren
|
||||
'/admin/tags': typeof AdminTagsRouteWithChildren
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/items/$itemId': typeof ItemsItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
@@ -168,6 +185,7 @@ export interface FileRoutesById {
|
||||
'/global-items/': typeof GlobalItemsIndexRoute
|
||||
'/setups/': typeof SetupsIndexRoute
|
||||
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
|
||||
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
|
||||
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
|
||||
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||
}
|
||||
@@ -180,6 +198,7 @@ export interface FileRouteTypes {
|
||||
| '/profile'
|
||||
| '/settings'
|
||||
| '/admin/items'
|
||||
| '/admin/tags'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/items/$itemId'
|
||||
| '/setups/$setupId'
|
||||
@@ -189,6 +208,7 @@ export interface FileRouteTypes {
|
||||
| '/global-items/'
|
||||
| '/setups/'
|
||||
| '/admin/items/$itemId'
|
||||
| '/admin/tags/$tagId'
|
||||
| '/threads/$threadId/'
|
||||
| '/threads/$threadId/candidates/$candidateId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -198,6 +218,7 @@ export interface FileRouteTypes {
|
||||
| '/profile'
|
||||
| '/settings'
|
||||
| '/admin/items'
|
||||
| '/admin/tags'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/items/$itemId'
|
||||
| '/setups/$setupId'
|
||||
@@ -207,6 +228,7 @@ export interface FileRouteTypes {
|
||||
| '/global-items'
|
||||
| '/setups'
|
||||
| '/admin/items/$itemId'
|
||||
| '/admin/tags/$tagId'
|
||||
| '/threads/$threadId'
|
||||
| '/threads/$threadId/candidates/$candidateId'
|
||||
id:
|
||||
@@ -217,6 +239,7 @@ export interface FileRouteTypes {
|
||||
| '/profile'
|
||||
| '/settings'
|
||||
| '/admin/items'
|
||||
| '/admin/tags'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/items/$itemId'
|
||||
| '/setups/$setupId'
|
||||
@@ -226,6 +249,7 @@ export interface FileRouteTypes {
|
||||
| '/global-items/'
|
||||
| '/setups/'
|
||||
| '/admin/items/$itemId'
|
||||
| '/admin/tags/$tagId'
|
||||
| '/threads/$threadId/'
|
||||
| '/threads/$threadId/candidates/$candidateId'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -340,6 +364,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/tags': {
|
||||
id: '/admin/tags'
|
||||
path: '/tags'
|
||||
fullPath: '/admin/tags'
|
||||
preLoaderRoute: typeof AdminTagsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/items': {
|
||||
id: '/admin/items'
|
||||
path: '/items'
|
||||
@@ -354,6 +385,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/tags/$tagId': {
|
||||
id: '/admin/tags/$tagId'
|
||||
path: '/$tagId'
|
||||
fullPath: '/admin/tags/$tagId'
|
||||
preLoaderRoute: typeof AdminTagsTagIdRouteImport
|
||||
parentRoute: typeof AdminTagsRoute
|
||||
}
|
||||
'/admin/items/$itemId': {
|
||||
id: '/admin/items/$itemId'
|
||||
path: '/$itemId'
|
||||
@@ -383,13 +421,27 @@ const AdminItemsRouteWithChildren = AdminItemsRoute._addFileChildren(
|
||||
AdminItemsRouteChildren,
|
||||
)
|
||||
|
||||
interface AdminTagsRouteChildren {
|
||||
AdminTagsTagIdRoute: typeof AdminTagsTagIdRoute
|
||||
}
|
||||
|
||||
const AdminTagsRouteChildren: AdminTagsRouteChildren = {
|
||||
AdminTagsTagIdRoute: AdminTagsTagIdRoute,
|
||||
}
|
||||
|
||||
const AdminTagsRouteWithChildren = AdminTagsRoute._addFileChildren(
|
||||
AdminTagsRouteChildren,
|
||||
)
|
||||
|
||||
interface AdminRouteChildren {
|
||||
AdminItemsRoute: typeof AdminItemsRouteWithChildren
|
||||
AdminTagsRoute: typeof AdminTagsRouteWithChildren
|
||||
AdminIndexRoute: typeof AdminIndexRoute
|
||||
}
|
||||
|
||||
const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminItemsRoute: AdminItemsRouteWithChildren,
|
||||
AdminTagsRoute: AdminTagsRouteWithChildren,
|
||||
AdminIndexRoute: AdminIndexRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Link, createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
createFileRoute,
|
||||
Link,
|
||||
Outlet,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
@@ -63,7 +63,10 @@ function TagInput({
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
@@ -76,7 +79,9 @@ function TagInput({
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => { if (inputValue) addTag(inputValue); }}
|
||||
onBlur={() => {
|
||||
if (inputValue) addTag(inputValue);
|
||||
}}
|
||||
placeholder={value.length === 0 ? "Add tags..." : ""}
|
||||
className="outline-none bg-transparent text-sm flex-1 min-w-[100px]"
|
||||
/>
|
||||
@@ -91,7 +96,11 @@ function AdminItemEditPage() {
|
||||
const id = Number(itemId);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: item, isLoading, isError } = useAdminGlobalItem(isNaN(id) ? null : id);
|
||||
const {
|
||||
data: item,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useAdminGlobalItem(Number.isNaN(id) ? null : id);
|
||||
const updateMutation = useUpdateAdminGlobalItem();
|
||||
const deleteMutation = useDeleteAdminGlobalItem();
|
||||
|
||||
@@ -121,7 +130,8 @@ function AdminItemEditPage() {
|
||||
model: item.model,
|
||||
category: item.category ?? "",
|
||||
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
||||
priceCents: item.priceCents != null ? String(item.priceCents / 100) : "",
|
||||
priceCents:
|
||||
item.priceCents != null ? String(item.priceCents / 100) : "",
|
||||
imageUrl: item.imageUrl ?? "",
|
||||
description: item.description ?? "",
|
||||
sourceUrl: item.sourceUrl ?? "",
|
||||
@@ -134,7 +144,9 @@ function AdminItemEditPage() {
|
||||
|
||||
// Fetch manufacturers for dropdown
|
||||
useEffect(() => {
|
||||
apiGet<Manufacturer[]>("/api/manufacturers").then(setManufacturers).catch(() => {});
|
||||
apiGet<Manufacturer[]>("/api/manufacturers")
|
||||
.then(setManufacturers)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function handleChange(
|
||||
@@ -146,7 +158,8 @@ function AdminItemEditPage() {
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const weightGrams = form.weightGrams !== "" ? Number(form.weightGrams) : null;
|
||||
const weightGrams =
|
||||
form.weightGrams !== "" ? Number(form.weightGrams) : null;
|
||||
const priceCents =
|
||||
form.priceCents !== "" ? Math.round(Number(form.priceCents) * 100) : null;
|
||||
|
||||
@@ -184,7 +197,10 @@ function AdminItemEditPage() {
|
||||
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 bg-gray-100 rounded-lg animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +210,9 @@ function AdminItemEditPage() {
|
||||
if (isError || !item) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center py-12">
|
||||
<p className="text-sm text-red-500">Failed to load item. Please try again.</p>
|
||||
<p className="text-sm text-red-500">
|
||||
Failed to load item. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -252,7 +270,9 @@ function AdminItemEditPage() {
|
||||
<label className={labelClass}>Brand (Manufacturer)</label>
|
||||
<select
|
||||
value={form.manufacturerId}
|
||||
onChange={(e) => handleChange("manufacturerId", Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleChange("manufacturerId", Number(e.target.value))
|
||||
}
|
||||
className={`${inputClass} appearance-none bg-white`}
|
||||
>
|
||||
<option value={0}>Select manufacturer...</option>
|
||||
|
||||
@@ -87,6 +87,7 @@ function AdminItemsPage() {
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
@@ -150,25 +151,36 @@ function AdminItemsPage() {
|
||||
key={item.id}
|
||||
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() =>
|
||||
navigate({ to: "/admin/items/$itemId", params: { itemId: String(item.id) } })
|
||||
navigate({
|
||||
to: "/admin/items/$itemId",
|
||||
params: { itemId: String(item.id) },
|
||||
})
|
||||
}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-gray-900">{item.brand}</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{item.brand}
|
||||
</span>
|
||||
<span className="text-gray-500 ml-1">{item.model}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700">
|
||||
{item.category ?? <span className="text-gray-300">—</span>}
|
||||
{item.category ?? (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700">
|
||||
{item.weightGrams != null
|
||||
? formatWeight(item.weightGrams)
|
||||
: <span className="text-gray-300">—</span>}
|
||||
{item.weightGrams != null ? (
|
||||
formatWeight(item.weightGrams)
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700">
|
||||
{item.priceCents != null
|
||||
? formatPrice(item.priceCents)
|
||||
: <span className="text-gray-300">—</span>}
|
||||
{item.priceCents != null ? (
|
||||
formatPrice(item.priceCents)
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.tags.length === 0 ? (
|
||||
@@ -209,7 +221,9 @@ function AdminItemsPage() {
|
||||
{/* Empty state (after load, no items) */}
|
||||
{!isLoading && allItems.length === 0 && !isError && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm font-medium text-gray-900">No items found</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
No items found
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Try a different search or clear your filters.
|
||||
</p>
|
||||
@@ -221,7 +235,9 @@ function AdminItemsPage() {
|
||||
|
||||
{/* Loading more */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="py-4 text-center text-sm text-gray-400">Loading...</div>
|
||||
<div className="py-4 text-center text-sm text-gray-400">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All loaded message */}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type AdminTag,
|
||||
useAdminTag,
|
||||
useAdminTags,
|
||||
useDeleteAdminTag,
|
||||
useUpdateAdminTag,
|
||||
type AdminTag,
|
||||
} from "../../hooks/useAdminTags";
|
||||
|
||||
export const Route = createFileRoute("/admin/tags/$tagId")({
|
||||
@@ -53,12 +53,19 @@ function AdminTagEditPage() {
|
||||
const id = Number(tagId);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: tag, isLoading, isError } = useAdminTag(isNaN(id) ? null : id);
|
||||
const {
|
||||
data: tag,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useAdminTag(Number.isNaN(id) ? null : id);
|
||||
const { data: allTags } = useAdminTags();
|
||||
const updateMutation = useUpdateAdminTag();
|
||||
const deleteMutation = useDeleteAdminTag();
|
||||
|
||||
const [form, setForm] = useState({ name: "", parentId: null as number | null });
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
parentId: null as number | null,
|
||||
});
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,7 +74,10 @@ function AdminTagEditPage() {
|
||||
}
|
||||
}, [tag]);
|
||||
|
||||
function handleChange(field: keyof typeof form, value: string | number | null) {
|
||||
function handleChange(
|
||||
field: keyof typeof form,
|
||||
value: string | number | null,
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
@@ -95,7 +105,10 @@ function AdminTagEditPage() {
|
||||
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 bg-gray-100 rounded-lg animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +118,9 @@ function AdminTagEditPage() {
|
||||
if (isError || !tag) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center py-12">
|
||||
<p className="text-sm text-red-500">Failed to load tag. Please try again.</p>
|
||||
<p className="text-sm text-red-500">
|
||||
Failed to load tag. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +164,10 @@ function AdminTagEditPage() {
|
||||
<select
|
||||
value={form.parentId ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange("parentId", e.target.value ? Number(e.target.value) : null)
|
||||
handleChange(
|
||||
"parentId",
|
||||
e.target.value ? Number(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
className={`${inputClass} appearance-none bg-white`}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type AdminTag,
|
||||
useAdminTags,
|
||||
useCreateAdminTag,
|
||||
type AdminTag,
|
||||
} from "../../hooks/useAdminTags";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
@@ -287,7 +287,9 @@ function AdminTagsPage() {
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-gray-900">No tags yet</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
No tags yet
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Add your first tag using the form above.
|
||||
</p>
|
||||
|
||||
@@ -202,7 +202,6 @@ function ImportExportSection() {
|
||||
</p>
|
||||
)}
|
||||
{importResult.errors.map((err, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
||||
<p key={i} className="text-red-600">
|
||||
{err}
|
||||
</p>
|
||||
|
||||
@@ -204,7 +204,9 @@ export const globalItems = pgTable(
|
||||
export const tags = pgTable("tags", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
parentId: integer("parent_id").references(() => tags.id, { onDelete: "set null" }),
|
||||
parentId: integer("parent_id").references(() => tags.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Context, Next } from "hono";
|
||||
import { users } from "../../db/schema.ts";
|
||||
import { getOrCreateUser, verifyApiKey } from "../services/auth.service";
|
||||
import { getOrCreateUncategorized } from "../services/category.service";
|
||||
|
||||
@@ -44,8 +44,8 @@ app.get("/", async (c) => {
|
||||
const result = await listGlobalItemsForAdmin(db, {
|
||||
query: q || undefined,
|
||||
tagNames,
|
||||
offset: isNaN(offset) ? 0 : offset,
|
||||
limit: isNaN(limit) || limit > 100 ? 50 : limit,
|
||||
offset: Number.isNaN(offset) ? 0 : offset,
|
||||
limit: Number.isNaN(limit) || limit > 100 ? 50 : limit,
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
@@ -62,19 +62,15 @@ app.get("/:id", async (c) => {
|
||||
});
|
||||
|
||||
// PUT /api/admin/items/:id — update item fields
|
||||
app.put(
|
||||
"/:id",
|
||||
zValidator("json", updateGlobalItemAdminSchema),
|
||||
async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const item = await updateGlobalItemById(db, id, data);
|
||||
if (!item) return c.json({ error: "Global item not found" }, 404);
|
||||
return c.json(item);
|
||||
},
|
||||
);
|
||||
app.put("/:id", zValidator("json", updateGlobalItemAdminSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const item = await updateGlobalItemById(db, id, data);
|
||||
if (!item) return c.json({ error: "Global item not found" }, 404);
|
||||
return c.json(item);
|
||||
});
|
||||
|
||||
// DELETE /api/admin/items/:id — delete item with FK cleanup
|
||||
app.delete("/:id", async (c) => {
|
||||
|
||||
@@ -178,7 +178,12 @@ export async function listGlobalItemsForAdmin(
|
||||
})
|
||||
.from(globalItemTags)
|
||||
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
|
||||
.where(sql`${globalItemTags.globalItemId} IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`);
|
||||
.where(
|
||||
sql`${globalItemTags.globalItemId} IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
|
||||
const tagsByItemId = new Map<number, string[]>();
|
||||
for (const row of tagRows) {
|
||||
@@ -194,7 +199,12 @@ export async function listGlobalItemsForAdmin(
|
||||
ownerCount: count(),
|
||||
})
|
||||
.from(items)
|
||||
.where(sql`${items.globalItemId} IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`)
|
||||
.where(
|
||||
sql`${items.globalItemId} IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
)
|
||||
.groupBy(items.globalItemId);
|
||||
|
||||
const ownerCountById = new Map<number, number>();
|
||||
@@ -241,16 +251,22 @@ export async function updateGlobalItemById(
|
||||
|
||||
// Build partial update — only set provided fields
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
if (fields.manufacturerId !== undefined) updateSet.manufacturerId = fields.manufacturerId;
|
||||
if (fields.manufacturerId !== undefined)
|
||||
updateSet.manufacturerId = fields.manufacturerId;
|
||||
if (fields.model !== undefined) updateSet.model = fields.model;
|
||||
if ("category" in fields) updateSet.category = fields.category ?? null;
|
||||
if ("weightGrams" in fields) updateSet.weightGrams = fields.weightGrams ?? null;
|
||||
if ("priceCents" in fields) updateSet.priceCents = fields.priceCents ?? null;
|
||||
if ("weightGrams" in fields)
|
||||
updateSet.weightGrams = fields.weightGrams ?? null;
|
||||
if ("priceCents" in fields)
|
||||
updateSet.priceCents = fields.priceCents ?? null;
|
||||
if ("imageUrl" in fields) updateSet.imageUrl = fields.imageUrl ?? null;
|
||||
if ("description" in fields) updateSet.description = fields.description ?? null;
|
||||
if ("description" in fields)
|
||||
updateSet.description = fields.description ?? null;
|
||||
if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null;
|
||||
if ("imageCredit" in fields) updateSet.imageCredit = fields.imageCredit ?? null;
|
||||
if ("imageSourceUrl" in fields) updateSet.imageSourceUrl = fields.imageSourceUrl ?? null;
|
||||
if ("imageCredit" in fields)
|
||||
updateSet.imageCredit = fields.imageCredit ?? null;
|
||||
if ("imageSourceUrl" in fields)
|
||||
updateSet.imageSourceUrl = fields.imageSourceUrl ?? null;
|
||||
|
||||
let item: typeof globalItems.$inferSelect | undefined;
|
||||
if (Object.keys(updateSet).length > 0) {
|
||||
@@ -295,14 +311,10 @@ export async function deleteGlobalItem(db: Db, id: number) {
|
||||
.where(eq(items.globalItemId, id));
|
||||
|
||||
// 3. Remove tag associations (FK: globalItemTags.globalItemId → globalItems.id, no cascade)
|
||||
await tx
|
||||
.delete(globalItemTags)
|
||||
.where(eq(globalItemTags.globalItemId, id));
|
||||
await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, id));
|
||||
|
||||
// 4. Delete the global item
|
||||
await tx
|
||||
.delete(globalItems)
|
||||
.where(eq(globalItems.id, id));
|
||||
await tx.delete(globalItems).where(eq(globalItems.id, id));
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -64,7 +64,11 @@ describe("Admin Tag Routes", () => {
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ id: expect.any(Number), name: "ultralight", parentId: null });
|
||||
expect(body).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
name: "ultralight",
|
||||
parentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates tag with parentId", async () => {
|
||||
|
||||
@@ -536,9 +536,15 @@ describe("listGlobalItemsForAdmin", () => {
|
||||
|
||||
it("filters by query string (brand/model)", async () => {
|
||||
const mfr = await insertManufacturer(db, "Salsa", "salsa");
|
||||
await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Woodsmoke 700" });
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: mfr.id,
|
||||
model: "Woodsmoke 700",
|
||||
});
|
||||
const mfr2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||
await insertGlobalItem(db, { manufacturerId: mfr2.id, model: "Racing Saddle Bag" });
|
||||
await insertGlobalItem(db, {
|
||||
manufacturerId: mfr2.id,
|
||||
model: "Racing Saddle Bag",
|
||||
});
|
||||
|
||||
const result = await listGlobalItemsForAdmin(db, { query: "salsa" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
@@ -547,7 +553,10 @@ describe("listGlobalItemsForAdmin", () => {
|
||||
|
||||
it("includes tags and ownerCount per item", async () => {
|
||||
const mfr = await insertManufacturer(db);
|
||||
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Test Item" });
|
||||
const globalItem = await insertGlobalItem(db, {
|
||||
manufacturerId: mfr.id,
|
||||
model: "Test Item",
|
||||
});
|
||||
const tag = await insertTag(db, "bikepacking");
|
||||
await tagGlobalItem(db, globalItem.id, tag.id!);
|
||||
|
||||
@@ -556,7 +565,9 @@ describe("listGlobalItemsForAdmin", () => {
|
||||
.insert(schema.users)
|
||||
.values({ logtoSub: "test-sub" })
|
||||
.returning();
|
||||
await insertItem(db, "My Test Item", user!.id, { globalItemId: globalItem.id });
|
||||
await insertItem(db, "My Test Item", user!.id, {
|
||||
globalItemId: globalItem.id,
|
||||
});
|
||||
|
||||
const result = await listGlobalItemsForAdmin(db);
|
||||
expect(result.items).toHaveLength(1);
|
||||
@@ -579,7 +590,10 @@ describe("updateGlobalItemById", () => {
|
||||
|
||||
it("updates model field by id", async () => {
|
||||
const mfr = await insertManufacturer(db);
|
||||
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Original" });
|
||||
const globalItem = await insertGlobalItem(db, {
|
||||
manufacturerId: mfr.id,
|
||||
model: "Original",
|
||||
});
|
||||
|
||||
await updateGlobalItemById(db, globalItem.id, { model: "Updated" });
|
||||
|
||||
@@ -589,9 +603,14 @@ describe("updateGlobalItemById", () => {
|
||||
|
||||
it("syncs tags when tags array provided", async () => {
|
||||
const mfr = await insertManufacturer(db);
|
||||
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Item" });
|
||||
const globalItem = await insertGlobalItem(db, {
|
||||
manufacturerId: mfr.id,
|
||||
model: "Tagged Item",
|
||||
});
|
||||
|
||||
await updateGlobalItemById(db, globalItem.id, { tags: ["cycling", "gravel"] });
|
||||
await updateGlobalItemById(db, globalItem.id, {
|
||||
tags: ["cycling", "gravel"],
|
||||
});
|
||||
|
||||
const result = await listGlobalItemsForAdmin(db);
|
||||
const found = result.items.find((i) => i.id === globalItem.id);
|
||||
@@ -614,7 +633,10 @@ describe("deleteGlobalItem", () => {
|
||||
|
||||
it("deletes item and returns true", async () => {
|
||||
const mfr = await insertManufacturer(db);
|
||||
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "To Delete" });
|
||||
const globalItem = await insertGlobalItem(db, {
|
||||
manufacturerId: mfr.id,
|
||||
model: "To Delete",
|
||||
});
|
||||
|
||||
const result = await deleteGlobalItem(db, globalItem.id);
|
||||
expect(result).toBe(true);
|
||||
@@ -625,12 +647,17 @@ describe("deleteGlobalItem", () => {
|
||||
|
||||
it("nullifies items.globalItemId before deleting", async () => {
|
||||
const mfr = await insertManufacturer(db);
|
||||
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Owned Item" });
|
||||
const globalItem = await insertGlobalItem(db, {
|
||||
manufacturerId: mfr.id,
|
||||
model: "Owned Item",
|
||||
});
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ logtoSub: "delete-test-sub" })
|
||||
.returning();
|
||||
const userItem = await insertItem(db, "User Item", user!.id, { globalItemId: globalItem.id });
|
||||
const userItem = await insertItem(db, "User Item", user!.id, {
|
||||
globalItemId: globalItem.id,
|
||||
});
|
||||
|
||||
await deleteGlobalItem(db, globalItem.id);
|
||||
|
||||
@@ -643,7 +670,10 @@ describe("deleteGlobalItem", () => {
|
||||
|
||||
it("removes globalItemTags before deleting", async () => {
|
||||
const mfr = await insertManufacturer(db);
|
||||
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Delete" });
|
||||
const globalItem = await insertGlobalItem(db, {
|
||||
manufacturerId: mfr.id,
|
||||
model: "Tagged Delete",
|
||||
});
|
||||
const tag = await insertTag(db, "delete-tag");
|
||||
await tagGlobalItem(db, globalItem.id, tag.id!);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { manufacturers } from "../../src/db/schema.ts";
|
||||
import {
|
||||
createManufacturer,
|
||||
getManufacturerBySlug,
|
||||
|
||||
@@ -5,16 +5,11 @@ import {
|
||||
deleteTag,
|
||||
getAdminTags,
|
||||
getAllTags,
|
||||
getTagWithCounts,
|
||||
updateTag,
|
||||
} from "../../src/server/services/tag.service.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
async function insertTag(
|
||||
db: any,
|
||||
name: string,
|
||||
parentId?: number | null,
|
||||
) {
|
||||
async function insertTag(db: any, name: string, parentId?: number | null) {
|
||||
const [row] = await db
|
||||
.insert(tags)
|
||||
.values({ name, parentId: parentId ?? null })
|
||||
@@ -108,7 +103,10 @@ describe("createTag", () => {
|
||||
|
||||
it("creates a tag with parentId set to an existing tag id", async () => {
|
||||
const parent = await createTag(db, { name: "gear" });
|
||||
const child = await createTag(db, { name: "clothing", parentId: parent.id });
|
||||
const child = await createTag(db, {
|
||||
name: "clothing",
|
||||
parentId: parent.id,
|
||||
});
|
||||
expect(child.parentId).toBe(parent.id);
|
||||
});
|
||||
});
|
||||
@@ -175,7 +173,7 @@ describe("deleteTag", () => {
|
||||
const parent = await insertTag(db, "parent");
|
||||
const child = await insertTag(db, "child", parent.id);
|
||||
await deleteTag(db, parent.id);
|
||||
const [childRow] = await db
|
||||
const [_childRow] = await db
|
||||
.select({ parentId: tags.parentId })
|
||||
.from(tags)
|
||||
.where((t: any) => t.id === child.id);
|
||||
|
||||
Reference in New Issue
Block a user