feat: user-chosen browse views with grid/list toggle and profile sync
Build & Push Docker Image / build (push) Successful in 2m25s
Build & Push Docker Image / build (push) Successful in 2m25s
- view modes grid | table | rows + density cozy/compact, persisted via localStorage and shareable ?view=?density= URL params (URL wins) - table view: sortable columns (name, rating, reviews), new sort options name_asc/name_desc/recently_updated (backend enum + handler) - live debounced search, removable filter chips, '/' focuses search - virtualization via @tanstack/react-virtual for table and rows views - profile sync: users.preferences jsonb + GET/PUT /api/auth/me/preferences; preference precedence URL > server profile > localStorage > default - add local rollup/lightningcss/tailwindcss oxide native binaries for macos dev
This commit is contained in:
@@ -308,6 +308,28 @@ export interface AuthUser {
|
||||
isLocal?: boolean;
|
||||
}
|
||||
|
||||
export type UserPreferencesView = typeof UserPreferencesView[keyof typeof UserPreferencesView];
|
||||
|
||||
|
||||
export const UserPreferencesView = {
|
||||
grid: 'grid',
|
||||
table: 'table',
|
||||
rows: 'rows',
|
||||
} as const;
|
||||
|
||||
export type UserPreferencesDensity = typeof UserPreferencesDensity[keyof typeof UserPreferencesDensity];
|
||||
|
||||
|
||||
export const UserPreferencesDensity = {
|
||||
cozy: 'cozy',
|
||||
compact: 'compact',
|
||||
} as const;
|
||||
|
||||
export interface UserPreferences {
|
||||
view?: UserPreferencesView;
|
||||
density?: UserPreferencesDensity;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
@@ -325,6 +347,9 @@ export const ListToolsSort = {
|
||||
newest: 'newest',
|
||||
top_rated: 'top_rated',
|
||||
most_reviewed: 'most_reviewed',
|
||||
name_asc: 'name_asc',
|
||||
name_desc: 'name_desc',
|
||||
recently_updated: 'recently_updated',
|
||||
} as const;
|
||||
|
||||
export type ListTrashedToolsParams = {
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
TrashToolsInput,
|
||||
User,
|
||||
UserCreateInput,
|
||||
UserPreferences,
|
||||
UserRoleUpdate,
|
||||
VersionInfo
|
||||
} from './api.schemas';
|
||||
@@ -1887,6 +1888,154 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
|
||||
|
||||
|
||||
|
||||
export const getGetMePreferencesUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/auth/me/preferences`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get current user's browse preferences
|
||||
*/
|
||||
export const getMePreferences = async ( options?: RequestInit): Promise<UserPreferences> => {
|
||||
|
||||
return customFetch<UserPreferences>(getGetMePreferencesUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetMePreferencesQueryKey = () => {
|
||||
return [
|
||||
`/api/auth/me/preferences`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetMePreferencesQueryOptions = <TData = Awaited<ReturnType<typeof getMePreferences>>, TError = ErrorType<ErrorResponse>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMePreferences>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMePreferencesQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMePreferences>>> = ({ signal }) => getMePreferences({ signal, ...requestOptions });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getMePreferences>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type GetMePreferencesQueryResult = NonNullable<Awaited<ReturnType<typeof getMePreferences>>>
|
||||
export type GetMePreferencesQueryError = ErrorType<ErrorResponse>
|
||||
|
||||
|
||||
/**
|
||||
* @summary Get current user's browse preferences
|
||||
*/
|
||||
|
||||
export function useGetMePreferences<TData = Awaited<ReturnType<typeof getMePreferences>>, TError = ErrorType<ErrorResponse>>(
|
||||
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMePreferences>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
|
||||
const queryOptions = getGetMePreferencesQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getUpdateMePreferencesUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/auth/me/preferences`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Update current user's browse preferences
|
||||
*/
|
||||
export const updateMePreferences = async (userPreferences: UserPreferences, options?: RequestInit): Promise<UserPreferences> => {
|
||||
|
||||
return customFetch<UserPreferences>(getUpdateMePreferencesUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
userPreferences,)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getUpdateMePreferencesMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext> => {
|
||||
|
||||
const mutationKey = ['updateMePreferences'];
|
||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, request: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateMePreferences>>, {data: BodyType<UserPreferences>}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return updateMePreferences(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type UpdateMePreferencesMutationResult = NonNullable<Awaited<ReturnType<typeof updateMePreferences>>>
|
||||
export type UpdateMePreferencesMutationBody = BodyType<UserPreferences>
|
||||
export type UpdateMePreferencesMutationError = ErrorType<ErrorResponse>
|
||||
|
||||
/**
|
||||
* @summary Update current user's browse preferences
|
||||
*/
|
||||
export const useUpdateMePreferences = <TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMePreferences>>,
|
||||
TError,
|
||||
{data: BodyType<UserPreferences>},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateMePreferencesMutationOptions(options));
|
||||
}
|
||||
|
||||
export const getListUsersUrl = () => {
|
||||
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [newest, top_rated, most_reviewed]
|
||||
enum: [newest, top_rated, most_reviewed, name_asc, name_desc, recently_updated]
|
||||
responses:
|
||||
"200":
|
||||
description: List of tools
|
||||
@@ -533,6 +533,48 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/auth/me/preferences:
|
||||
get:
|
||||
operationId: getMePreferences
|
||||
tags: [auth]
|
||||
summary: Get current user's browse preferences
|
||||
responses:
|
||||
"200":
|
||||
description: User preferences
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserPreferences"
|
||||
"401":
|
||||
description: Not authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
put:
|
||||
operationId: updateMePreferences
|
||||
tags: [auth]
|
||||
summary: Update current user's browse preferences
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserPreferences"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated preferences
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserPreferences"
|
||||
"401":
|
||||
description: Not authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/users:
|
||||
get:
|
||||
operationId: listUsers
|
||||
@@ -1047,6 +1089,16 @@ components:
|
||||
isLocal:
|
||||
type: boolean
|
||||
|
||||
UserPreferences:
|
||||
type: object
|
||||
properties:
|
||||
view:
|
||||
type: string
|
||||
enum: [grid, table, rows]
|
||||
density:
|
||||
type: string
|
||||
enum: [cozy, compact]
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required: [error]
|
||||
|
||||
@@ -35,7 +35,7 @@ export const GetVersionResponse = zod.object({
|
||||
export const ListToolsQueryParams = zod.object({
|
||||
"category": zod.coerce.string().optional(),
|
||||
"search": zod.coerce.string().optional(),
|
||||
"sort": zod.enum(['newest', 'top_rated', 'most_reviewed']).optional()
|
||||
"sort": zod.enum(['newest', 'top_rated', 'most_reviewed', 'name_asc', 'name_desc', 'recently_updated']).optional()
|
||||
})
|
||||
|
||||
export const ListToolsResponseItem = zod.object({
|
||||
@@ -429,6 +429,29 @@ export const GetMeResponse = zod.object({
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @summary Get current user's browse preferences
|
||||
*/
|
||||
export const GetMePreferencesResponse = zod.object({
|
||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||
"density": zod.enum(['cozy', 'compact']).optional()
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @summary Update current user's browse preferences
|
||||
*/
|
||||
export const UpdateMePreferencesBody = zod.object({
|
||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||
"density": zod.enum(['cozy', 'compact']).optional()
|
||||
})
|
||||
|
||||
export const UpdateMePreferencesResponse = zod.object({
|
||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||
"density": zod.enum(['cozy', 'compact']).optional()
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @summary List all local users (admin only)
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,9 @@ export * from './user';
|
||||
export * from './userCreateInput';
|
||||
export * from './userCreateInputRole';
|
||||
export * from './userCreateInputTier';
|
||||
export * from './userPreferences';
|
||||
export * from './userPreferencesDensity';
|
||||
export * from './userPreferencesView';
|
||||
export * from './userRole';
|
||||
export * from './userRoleUpdate';
|
||||
export * from './userRoleUpdateRole';
|
||||
|
||||
@@ -13,4 +13,7 @@ export const ListToolsSort = {
|
||||
newest: 'newest',
|
||||
top_rated: 'top_rated',
|
||||
most_reviewed: 'most_reviewed',
|
||||
name_asc: 'name_asc',
|
||||
name_desc: 'name_desc',
|
||||
recently_updated: 'recently_updated',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* ToolRate API — Tool listing and rating platform
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { UserPreferencesDensity } from './userPreferencesDensity';
|
||||
import type { UserPreferencesView } from './userPreferencesView';
|
||||
|
||||
export interface UserPreferences {
|
||||
view?: UserPreferencesView;
|
||||
density?: UserPreferencesDensity;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* ToolRate API — Tool listing and rating platform
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type UserPreferencesDensity = typeof UserPreferencesDensity[keyof typeof UserPreferencesDensity];
|
||||
|
||||
|
||||
export const UserPreferencesDensity = {
|
||||
cozy: 'cozy',
|
||||
compact: 'compact',
|
||||
} as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* ToolRate API — Tool listing and rating platform
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type UserPreferencesView = typeof UserPreferencesView[keyof typeof UserPreferencesView];
|
||||
|
||||
|
||||
export const UserPreferencesView = {
|
||||
grid: 'grid',
|
||||
table: 'table',
|
||||
rows: 'rows',
|
||||
} as const;
|
||||
@@ -1,7 +1,12 @@
|
||||
import { pgTable, text, serial, timestamp } from "drizzle-orm/pg-core";
|
||||
import { pgTable, text, serial, timestamp, jsonb } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export type UserPreferences = {
|
||||
view?: "grid" | "table" | "rows";
|
||||
density?: "cozy" | "compact";
|
||||
};
|
||||
|
||||
export const usersTable = pgTable("users", {
|
||||
id: serial("id").primaryKey(),
|
||||
username: text("username").notNull().unique(),
|
||||
@@ -12,6 +17,7 @@ export const usersTable = pgTable("users", {
|
||||
authProvider: text("auth_provider").notNull().default("local"),
|
||||
authProviderId: text("auth_provider_id"),
|
||||
displayName: text("display_name"),
|
||||
preferences: jsonb("preferences").$type<UserPreferences>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user