feat: user-chosen browse views with grid/list toggle and profile sync
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:
opencode
2026-08-02 09:34:53 +02:00
parent bcc74328d6
commit 78244c3197
22 changed files with 1061 additions and 73 deletions
+58
View File
@@ -2,6 +2,7 @@ import { Router, type IRouter, type Request } from "express";
import { Issuer, generators, type Client } from "openid-client";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db, usersTable } from "@workspace/db";
import { logger } from "../lib/logger";
import { getEntitlements } from "../middleware/feature";
@@ -278,4 +279,61 @@ router.get("/auth/me", async (req, res): Promise<void> => {
});
});
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
async function resolveDbUser(u: SessionUser) {
if (u.isLocal) {
const id = Number(u.sub);
if (Number.isFinite(id)) {
const [byId] = await db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
if (byId) return byId;
}
if (u.preferred_username) {
const [byName] = await db.select().from(usersTable).where(eq(usersTable.username, u.preferred_username)).limit(1);
if (byName) return byName;
}
return null;
}
const [byProvider] = await db.select().from(usersTable).where(eq(usersTable.authProviderId, u.sub)).limit(1);
return byProvider ?? null;
}
const PreferenceSchema = z.object({
view: z.enum(["grid", "table", "rows"]).optional(),
density: z.enum(["cozy", "compact"]).optional(),
});
router.get("/auth/me/preferences", async (req, res): Promise<void> => {
if (!req.session.user) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const dbUser = await resolveDbUser(req.session.user);
if (!dbUser) {
res.status(401).json({ error: "User not found" });
return;
}
res.json(dbUser.preferences ?? {});
});
router.put("/auth/me/preferences", async (req, res): Promise<void> => {
if (!req.session.user) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const parsed = PreferenceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const dbUser = await resolveDbUser(req.session.user);
if (!dbUser) {
res.status(401).json({ error: "User not found" });
return;
}
const merged = { ...(dbUser.preferences ?? {}), ...parsed.data };
await db.update(usersTable).set({ preferences: merged }).where(eq(usersTable.id, dbUser.id));
res.json(merged);
});
export default router;
+6
View File
@@ -87,6 +87,12 @@ router.get("/tools", async (req, res): Promise<void> => {
result = result.sort((a, b) => (b.avgCombined ?? 0) - (a.avgCombined ?? 0));
} else if (sort === "most_reviewed") {
result = result.sort((a, b) => b.ratingCount - a.ratingCount);
} else if (sort === "name_asc") {
result = result.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
} else if (sort === "name_desc") {
result = result.sort((a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: "base" }));
} else if (sort === "recently_updated") {
result = result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
res.json(result);