Skip to content

Implement filtering and sorting, and set up drizzle client on web#36

Merged
Shunseii merged 8 commits intomainfrom
feat/tags-filter-and-sorting
Jan 19, 2026
Merged

Implement filtering and sorting, and set up drizzle client on web#36
Shunseii merged 8 commits intomainfrom
feat/tags-filter-and-sorting

Conversation

@Shunseii
Copy link
Copy Markdown
Owner

@Shunseii Shunseii commented Jan 18, 2026

Summary by CodeRabbit

  • New Features
    • Dictionary filters panel with tag selection, tag popover, sort options (relevance, recently added, recently updated) and "Clear all filters".
  • UI/UX Improvements
    • Improved tag pills with easier removal on touch and click.
    • Refined dictionary layout with card-based list presentation and smoother infinite scroll handling.
    • New scroll area for improved scrolling behavior.
  • Localization
    • Added/updated translations for filters, tags, sort labels and related UI strings.

✏️ Tip: You can customize this high-level summary in your review settings.

@Shunseii Shunseii self-assigned this Jan 18, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 18, 2026

📝 Walkthrough

Walkthrough

Adds tag-based filtering and sorting to the dictionary UI, extends search hooks and backend search to accept filters/sort, integrates Drizzle ORM for local DB access, updates search schema/tokenizer and related UI components, and adds localization and UI utility components.

Changes

Cohort / File(s) Summary
Dependency updates
\apps/web/package.json`, `packages/drizzle-user-db-schemas/package.json`, `packages/web-ui/package.json``
Added @tanstack/zod-adapter; added @radix-ui/react-scroll-area; updated drizzle-orm to 1.0.0-beta.10-4a43a22.
Filter UI & Tag components
\apps/web/src/components/TagsCombobox.tsx`, `apps/web/src/components/features/dictionary/filters/DictionaryFilters.tsx`, `apps/web/src/components/features/dictionary/filters/TagsFilter.tsx``
New TagPill component; new DictionaryFilters panel and TagsFilter popover that fetch tags from DB, provide search, sync tags/sort to URL, and render active tag pills with clear/reset behavior.
Search hook & integration
\apps/web/src/hooks/search/useSearch.ts`, `apps/web/src/hooks/db/index.ts`, `apps/web/src/hooks/db/`, `apps/web/src/routes/.../.tsx``
Added SORT_OPTIONS; extended useInfiniteScroll/useSearch to accept filters (tags) and sort, propagate where/sortBy to search; updated imports/paths to hooks/search/useSearch.
Infinite scroll & search UI
\apps/web/src/components/search/InfiniteScroll.tsx`, `apps/web/src/routes/_authorized-layout/_search-layout/index.lazy.tsx`, `apps/web/src/routes/_authorized-layout/_search-layout/route.tsx``
InfiniteScroll now accepts searchQuery prop, uses useSet for expanded state, refactored to Card-based layout, new loading/button primitives; search layout composes DictionaryFilters and passes deferred searchQuery; route adds zod-based validateSearch using SORT_OPTIONS.
Database / Drizzle integration
\apps/web/src/lib/db/index.ts`, `apps/web/src/lib/db/operations/dictionary-entries.ts`, `packages/drizzle-user-db-schemas/package.json`*`
Introduced a drizzle ORM instance and getDrizzleDb() accessor; drizzle instance reset on DB reset; dictionaryEntriesTable.tags.query signature now accepts optional searchTerm and conditionally applies LIKE filter.
Search backend & schema/tokenizer
\packages/search/src/database.ts`, `packages/search/src/schema.ts`, `packages/search/src/tokenizer.ts`, `packages/search/src/*`**`
Consolidated searchable properties to PROPERTIES and BOOST; introduced SearchDictionaryOptions (adds where/sortBy); searchDictionary updated to accept options and pass through where/sortBy; schema removes exact-variant fields and tags becomes enum[]; tokenizer removed exact-field Arabic branch.
UI primitives & small UI tweaks
\packages/web-ui/src/components/scroll-area.tsx`, `apps/web/src/components/features/decks/DeckDialogContent.tsx`, `apps/web/src/routes/_authorized-layout/_app-layout/dictionary/edit/$wordId.tsx`, `apps/web/src/hooks/db/index.ts`, `apps/web/src/routes/_authorized-layout/route.tsx``
Added ScrollArea/ScrollBar components; minor Tailwind classname tweak in DeckDialogContent; BackButton switched to useRouter().history.back(); updated hook import paths to hooks/search/useSearch.
Localization updates
\packages/i18n/locales/ar.po`, `packages/i18n/locales/en.po`, `packages/i18n/locales/ar.ts`, `packages/i18n/locales/en.ts`**`
Added/updated Arabic and English translations and pluralization entries for filter/tag UI and sort options (Filters, Sort by, Clear all filters, tag pluralizations, etc.).

Notes: grouped related files with ellipses and kept cohorts minimal for review focus.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant DictionaryFilters as DictionaryFilters
    participant Router as Router (URL)
    participant SearchLayout as SearchLayout
    participant InfiniteScroll as InfiniteScroll
    participant useSearch as useSearch (Hook)
    participant searchDictionary as searchDictionary
    participant DB as DB (Orama/Drizzle)

    User->>DictionaryFilters: select tag / choose sort
    DictionaryFilters->>Router: update URL search params (tags, sort)
    Router->>SearchLayout: route/search params change
    SearchLayout->>InfiniteScroll: pass deferred searchQuery prop
    InfiniteScroll->>useSearch: request results(term, filters, sort)
    useSearch->>searchDictionary: call with term, where, sortBy, offset
    searchDictionary->>DB: execute search with where & sort
    DB->>searchDictionary: return results
    searchDictionary->>useSearch: return paginated results
    useSearch->>InfiniteScroll: return data
    InfiniteScroll->>User: render filtered & sorted entries
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through tags and sorted dew,

A pill and popover, choices anew,
Drizzle set the stream to flow,
Queries hum and results now show,
Hooray — filters stitched in true!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main objectives of this pull request: implementing filtering and sorting capabilities for the dictionary, and setting up the drizzle ORM client on the web application.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Jan 18, 2026

Deploying bahar with  Cloudflare Pages  Cloudflare Pages

Latest commit: a02a35a
Status: ✅  Deploy successful!
Preview URL: https://79dde2fb.bahar-5xu.pages.dev
Branch Preview URL: https://feat-tags-filter-and-sorting.bahar-5xu.pages.dev

View logs

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/src/lib/db/operations/dictionary-entries.ts (1)

53-66: Fix parameter binding when searchTerm is absent.

When searchTerm is falsy, the SQL omits the ? placeholder but .all([searchTerm]) still passes one parameter, causing a "incorrect number of bindings" error at runtime. Bind params only when the placeholder exists.

🐛 Suggested fix
-        const res: { tag: string; count: number }[] = await db
-          .prepare(
-            `SELECT value as tag, COUNT(*) as count
-             FROM dictionary_entries, json_each(tags)
-             WHERE value IS NOT NULL ${searchTerm ? "AND value LIKE '%' || ? || '%'" : ""}
-             GROUP BY value
-             ORDER BY count DESC;`
-          )
-          .all([searchTerm]);
+        const hasSearchTerm = typeof searchTerm === "string" && searchTerm.length > 0;
+        const sql = `SELECT value as tag, COUNT(*) as count
+             FROM dictionary_entries, json_each(tags)
+             WHERE value IS NOT NULL ${hasSearchTerm ? "AND value LIKE '%' || ? || '%'" : ""}
+             GROUP BY value
+             ORDER BY count DESC;`;
+
+        const res: { tag: string; count: number }[] = await db
+          .prepare(sql)
+          .all(hasSearchTerm ? [searchTerm] : []);
apps/web/src/hooks/search/useSearch.ts (1)

180-198: Missing dependencies in useEffect for offset changes.

This effect uses sortBy, params.term, whereFilter, and searchQueryLanguage but only includes [offset, setHits, search] in the dependency array. This can cause stale closures where outdated filter/sort values are used when offset changes.

Proposed fix
   useEffect(() => {
     // Don't search when offset is 0 since it
     // was already handled in the other useEffect
     if (offset === 0) return;

     const { hits } = search(
       {
         offset,
         sortBy,
         term: params.term,
         where: whereFilter,
       },
       searchQueryLanguage
     );

     setHits((previousHits) =>
       previousHits ? [...previousHits, ...hits] : hits
     );
-  }, [offset, setHits, search]);
+  }, [offset, setHits, search, sortBy, params.term, whereFilter, searchQueryLanguage]);
packages/search/src/database.ts (1)

199-204: Merging logic may conflict with sortBy ordering.

When sortBy is provided, this merge strategy always places exact matches before fuzzy matches, which breaks the expected sort order. For example, if sorting by "recently added", a newer fuzzy match would incorrectly appear after an older exact match.

Consider either:

  1. When sortBy is specified, skip the two-pass merge and use a single search pass
  2. Re-sort the merged results after combining both passes
Suggested approach: single-pass when sortBy is specified
 export const searchDictionary = (
   db: DictionaryOrama,
   term: string,
   options?: SearchDictionaryOptions
 ) => {
   const limit = options?.limit ?? 10;
   const offset = options?.offset ?? 0;
   const language = options?.language;
+  const hasSortBy = !!options?.sortBy;

-  if (!term) {
+  // When sortBy is specified or no term, use single-pass search
+  if (!term || hasSortBy) {
     return search(
       db,
       {
         term,
         limit,
         offset,
+        properties: PROPERTIES,
+        boost: BOOST,
+        tolerance: term ? (stripArabicDiacritics(term).length <= 2 ? 0 : stripArabicDiacritics(term).length <= 4 ? 1 : 2) : undefined,
         where: options?.where,
         sortBy: options?.sortBy,
       },
       language
     );
   }
🤖 Fix all issues with AI agents
In `@apps/web/package.json`:
- Around line 37-43: The package.json currently includes `@tanstack/zod-adapter`
which requires zod@^3; remove the "@tanstack/zod-adapter" entry or replace it
with a version that supports Zod v4 (if one exists) so peer-dependency
resolution no longer conflicts with zod@4.0.17; alternatively, delete the
dependency and rely on native Zod v4 features (or update any imports/usages
referencing `@tanstack/zod-adapter`) to ensure code compiles with zod@4 used by
drizzle-zod/drizzle-orm.

In `@apps/web/src/components/features/dictionary/filters/DictionaryFilters.tsx`:
- Line 38: hasActiveFilters currently only checks filteredTags, so picking a
non-default sort doesn't mark filters active; update the boolean to also
consider whether the current sort differs from the component's default sort
(e.g., compute isSortActive = sort !== <defaultSortValueUsedInComponent> and set
hasActiveFilters = (filteredTags?.length ?? 0) > 0 || isSortActive), and apply
the same change to the other occurrence around the block handling lines 144-155
so both places use filteredTags, sort, and the component's default sort value.

In `@apps/web/src/components/features/dictionary/filters/TagsFilter.tsx`:
- Around line 70-89: When updating tags inside TagsFilter (the onSelect handler
that checks filteredTags and calls navigate), include the current sort search
param in the navigate search object so it isn't lost; retrieve the current sort
value the component uses (same source as DictionaryFilters, e.g., the existing
sort/filter state or search params) and add sort: currentSort (or undefined if
absent) alongside tags in both the removal and addition navigate calls,
mirroring the pattern used in DictionaryFilters to preserve both tags and sort.

In `@apps/web/src/components/search/InfiniteScroll.tsx`:
- Around line 448-456: The toggleExpanded callback uses the expandedIds set
returned from useSet but declares an empty dependency array; update the
useCallback for toggleExpanded to include expandedIds in its dependency array so
the hook correctly depends on the stable set reference (i.e., change
useCallback(..., []) to useCallback(..., [expandedIds])), ensuring the
exhaustive-deps rule is satisfied while retaining existing logic that
adds/deletes ids from expandedIds.

In `@apps/web/src/components/TagsCombobox.tsx`:
- Around line 21-52: The outer <button> in the TagsCombobox component is
wrapping an inner <button> (the X icon) which is invalid HTML; change the outer
element (the one using props disabled, tagValue, onClick and rendering the inner
<button> with <X />) to a non-button container (e.g., <span> or <div>) and
implement accessible behavior: add role="button", aria-disabled when disabled, a
tabindex (when not disabled), and onKeyDown to activate on Enter/Space and call
onClick just like the original handler; also remove the misplaced key={tagValue}
from inside this component (keys belong at the call site). Ensure
event.stopPropagation() semantics for both the outer container handlers and the
inner close button handlers remain the same.

In `@apps/web/src/hooks/search/useSearch.ts`:
- Around line 202-217: The useEffect in useSearch.ts that calls search(...) can
run with stale values because its dependency array omits sortBy, params.term,
whereFilter, and searchQueryLanguage (and setHasMore); update the dependency
array for that effect (the useEffect that sets offset, hits, metadata, and
hasMore) to include sortBy, params.term (or params), whereFilter,
searchQueryLanguage, and setHasMore in addition to the existing dependencies
(paramsKey, setOffset, setHits, setSearchResultsMetadata, search) so the effect
re-runs whenever those memoized/derived values change.

In
`@apps/web/src/routes/_authorized-layout/_app-layout/dictionary/edit/`$wordId.tsx:
- Around line 198-217: Replace the direct history.back() call with TanStack
Router's can-go-back check: import and call useCanGoBack() alongside
useRouter(), then update the Button onClick to conditionally call
router.history.back() if canGoBack is true, otherwise perform a safe in-app
navigation via router.navigate (e.g., navigate to your dictionary/listing
route). Reference: useCanGoBack(), router, router.history.back(),
router.navigate().

In `@packages/drizzle-user-db-schemas/package.json`:
- Line 13: The package.json pins the peer dependency "drizzle-orm" to a specific
pre-release ("1.0.0-beta.10-4a43a22"); change the "drizzle-orm" entry in
package.json to a stable or flexible semver range (e.g., "^1.0.0" or ">=0.23.13
<2.0.0") instead of the specific beta, save the file, and regenerate lockfiles
(npm/yarn/pnpm) so consumers aren't forced to that pre-release; update the
peerDependencies/devDependencies entries referencing "drizzle-orm" if present
and run your install to verify no conflicts.

In `@packages/web-ui/src/components/scroll-area.tsx`:
- Around line 23-43: The component ScrollBar is importing non-existent Radix
exports ScrollAreaPrimitive.ScrollAreaScrollbar and
ScrollAreaPrimitive.ScrollAreaThumb; update all uses to the correct v1.2.10
exports ScrollAreaPrimitive.Scrollbar and ScrollAreaPrimitive.Thumb (including
the JSX elements and the displayName assignment), i.e., replace references to
ScrollAreaPrimitive.ScrollAreaScrollbar with ScrollAreaPrimitive.Scrollbar and
ScrollAreaPrimitive.ScrollAreaThumb with ScrollAreaPrimitive.Thumb inside the
ScrollBar forwardRef component and when setting ScrollBar.displayName.
🧹 Nitpick comments (8)
apps/web/src/components/features/decks/DeckDialogContent.tsx (1)

174-184: Consider logging the error for debugging purposes.

The underscore prefix convention for unused variables is appropriate. However, silently discarding the error makes debugging production issues difficult. Consider logging the error before showing the user-friendly toast.

💡 Suggested improvement
-    } catch (_err) {
+    } catch (err) {
+      console.error("Deck operation failed:", err);
       if (isEditing) {
packages/search/src/schema.ts (2)

36-51: Stale interface: IndexedMorphology still contains _exact fields.

The schema removed exact-variant fields (per AI summary), but IndexedMorphology still defines singular_exact, plurals_exact, past_tense_exact, present_tense_exact, and masadir_exact. These should likely be removed to keep the interface consistent with the schema.

Proposed cleanup
 export interface IndexedMorphology {
   ism?: {
     singular?: string;
     plurals?: string[];
-    singular_exact?: string;
-    plurals_exact?: string[];
   };
   verb?: {
     past_tense?: string;
     present_tense?: string;
     masadir?: string[];
-    past_tense_exact?: string;
-    present_tense_exact?: string;
-    masadir_exact?: string[];
   };
 }

57-73: Stale interface: DictionaryDocument still contains word_exact.

Similar to IndexedMorphology, the word_exact field on line 60 should be removed if exact-variant fields were intentionally removed from the schema.

Proposed cleanup
 export interface DictionaryDocument {
   id: string;
   word: string;
-  word_exact?: string;
   translation: string;
   // ... rest unchanged
 }
apps/web/src/lib/db/index.ts (1)

175-207: Drizzle proxy implementation looks correct, but silent failure may mask issues.

The sqlite-proxy driver implementation correctly handles all method types (run, all, values, get). However, Line 177 silently returns { rows: [] } when db is null, which could mask initialization issues during debugging.

Consider logging or throwing when this unexpected state occurs:

Optional: Add warning for unexpected null db
     async (sql, params, method) => {
-      if (!db) return { rows: [] };
+      if (!db) {
+        console.warn("Drizzle query attempted before db initialization");
+        return { rows: [] };
+      }
apps/web/src/hooks/search/useSearch.ts (1)

144-162: Using paramsKey for memo dependencies is acceptable but consider individual deps.

Using JSON.stringify(params) as the dependency works but has overhead. Since params.filters?.tags and params.sort are the actual dependencies, using them directly would be more precise. However, this is a minor optimization concern.

Optional: Use specific dependencies
   const whereFilter = useMemo<SearchDictionaryOptions["where"]>(() => {
     if (!params.filters?.tags?.length) return undefined;

     return {
       tags: { containsAll: params.filters.tags },
     };
-  }, [paramsKey]);
+  }, [params.filters?.tags]);

   const sortBy = useMemo<SearchDictionaryOptions["sortBy"]>(() => {
     if (!params.sort || params.sort === "relevance") return undefined;

     return {
       property:
         params.sort === "createdAt"
           ? "created_at_timestamp_ms"
           : "updated_at_timestamp_ms",
       order: "DESC",
     };
-  }, [paramsKey]);
+  }, [params.sort]);
apps/web/src/components/features/dictionary/filters/DictionaryFilters.tsx (1)

17-31: Avoid duplicating sort options.

Consider sourcing the UI list from the same shared constant used by the search hook so UI/options and search validation can’t drift.

apps/web/src/routes/_authorized-layout/_search-layout/index.lazy.tsx (2)

28-31: Keep header counts aligned with the rendered results.

InfiniteScroll now uses deferredSearchQuery, but the header counts come from useSearch() directly. During fast typing, the counts can reflect a newer query than the list. Consider basing both on the same query value to avoid mismatched UI.

Also applies to: 53-57, 165-165


155-156: Remove the commented‑out divider.

It’s dead code and adds noise to the layout. As per coding guidelines, avoid commented‑out code.

🧹 Cleanup
-            {/*<div className="mx-4 h-px bg-linear-to-r from-border/50 via-border to-border/50 sm:mx-6" />*/}

Comment thread apps/web/package.json
Comment on lines +37 to +43
"@tanstack/zod-adapter": "^1.150.0",
"@tursodatabase/sync-wasm": "^0.3.2",
"@uidotdev/usehooks": "^2.4.1",
"better-auth": "1.4.9",
"date-fns": "^3.6.0",
"dompurify": "^3.3.0",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "1.0.0-beta.10-4a43a22",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

@tanstack/zod-adapter 1.150.0 compatibility requirements zod 4 @tanstack/react-router``

💡 Result:

Short answer:

  • @tanstack/zod-adapter@1.150.0 is intended for Zod v3 (peerDependency: zod ^3.23.8) and requires a TanStack Router / React Router version >= 1.43.2. [1][2]
  • If you want to use Zod v4: recent Zod releases (>= v4.0.6) added behavior that makes the adapter unnecessary for many use-cases (the repo issue notes Zod‑4 compatibility/workarounds and that Zod >= 4.0.6 works natively). If you rely on any adapter-specific helpers (e.g. fallback behavior), check the issue discussion and test with your exact versions. [2][3]

Sources:

  1. TanStack release listing showing @tanstack/zod-adapter@1.150.0. [1]
  2. @tanstack/zod-adapter package peerDependency info / docs (zod: ^3.23.8, @tanstack/react-router: >=1.43.2). [2]
  3. GitHub issue discussing Zod v4 support and notes about Zod >= 4.0.6. [3]

🌐 Web query:

drizzle-orm 1.0.0-beta.10-4a43a22 compatibility drizzle-zod 0.8.3 zod

💡 Result:

Short answer: Generally compatible — drizzle-zod@0.8.3 supports Zod v3 (>=3.25.1) and v4 and declares drizzle-orm >=0.36.0 as a peer, so it should work with a drizzle-orm 1.0.0-beta build. [1][2][3]

Notes / caveats:

  • drizzle-zod docs state requirement: Drizzle ORM >= 0.36.0 and Zod >= 3.25.1 (with v4 supported). [2]
  • npm shows drizzle-zod@0.8.3 published recently. [3]
  • There are GitHub issues about peer-dependency bumps for beta drizzle-orm builds (some beta tags can cause npm peer-dep resolution warnings); if you use a specific 1.0.0-beta commit tag you may see peerDep warnings and might need to align package tags or use package-manager overrides/resolutions. [4]

If you want, tell me the exact drizzle-orm beta tag you’re using (full version string) and your package manager (npm/pnpm/yarn) and I’ll check for any known breakages or provide a concrete install/resolution command.

References:
[1] Drizzle docs — drizzle-zod page (requirements).
[2] Drizzle docs / changelog notes about zod support.
[3] npm: drizzle-zod (v0.8.3).
[4] GitHub issue re: peer dependency bumps for beta builds.


Remove or downgrade @tanstack/zod-adapter — it requires Zod v3 but the project uses Zod v4.

@tanstack/zod-adapter@1.150.0 declares a peer dependency of zod@^3.23.8 and is incompatible with zod@4.0.17. This will cause peer-dependency resolution warnings or failures. Since Zod v4 (>= 4.0.6) added native support for the adapter's functionality, consider removing the adapter entirely or using a newer adapter version if one exists for Zod v4.

The drizzle-orm@1.0.0-beta.10-4a43a22 is compatible with drizzle-zod@0.8.3 and zod@4.0.17.

🤖 Prompt for AI Agents
In `@apps/web/package.json` around lines 37 - 43, The package.json currently
includes `@tanstack/zod-adapter` which requires zod@^3; remove the
"@tanstack/zod-adapter" entry or replace it with a version that supports Zod v4
(if one exists) so peer-dependency resolution no longer conflicts with
zod@4.0.17; alternatively, delete the dependency and rely on native Zod v4
features (or update any imports/usages referencing `@tanstack/zod-adapter`) to
ensure code compiles with zod@4 used by drizzle-zod/drizzle-orm.

Comment thread apps/web/src/components/features/dictionary/filters/DictionaryFilters.tsx Outdated
Comment thread apps/web/src/components/features/dictionary/filters/TagsFilter.tsx
Comment on lines +448 to 456
const expandedIds = useSet<string>();

const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
if (expandedIds.has(id)) {
expandedIds.delete(id);
} else {
expandedIds.add(id);
}
}, []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing dependency in useCallback for toggleExpanded.

The toggleExpanded callback references expandedIds but has an empty dependency array. While useSet returns a stable reference, the callback should include expandedIds for correctness and to satisfy the exhaustive-deps rule.

Proposed fix
   const toggleExpanded = useCallback((id: string) => {
     if (expandedIds.has(id)) {
       expandedIds.delete(id);
     } else {
       expandedIds.add(id);
     }
-  }, []);
+  }, [expandedIds]);
🤖 Prompt for AI Agents
In `@apps/web/src/components/search/InfiniteScroll.tsx` around lines 448 - 456,
The toggleExpanded callback uses the expandedIds set returned from useSet but
declares an empty dependency array; update the useCallback for toggleExpanded to
include expandedIds in its dependency array so the hook correctly depends on the
stable set reference (i.e., change useCallback(..., []) to useCallback(...,
[expandedIds])), ensuring the exhaustive-deps rule is satisfied while retaining
existing logic that adds/deletes ids from expandedIds.

Comment thread apps/web/src/components/TagsCombobox.tsx
Comment thread apps/web/src/hooks/search/useSearch.ts
"peerDependencies": {
"zod": "4.0.17",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "1.0.0-beta.10-4a43a22",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

drizzle-orm 1.0.0-beta.10-4a43a22 npm package release

💡 Result:

The npm release "drizzle-orm@1.0.0-beta.10-4a43a22" is a published beta build (part of the 1.0.0-beta.10 series) — listed on package registries with a publish date of January 9, 2026. [1][2][3]

Sources:

  • Yarn package versions list (shows 1.0.0-beta.10-4a43a22, Jan 9, 2026). [1]
  • NewReleases summary listing that build among recent beta releases. [2]
  • Drizzle ORM releases page (context about the 1.0 beta series). [3]

Using a pre-release peer dependency for drizzle-orm introduces fragmentation risk.

The version 1.0.0-beta.10-4a43a22 exists on npm and satisfies the peer dependency requirement (drizzle-zod@0.5.1 requires >=0.23.13), but publishing a beta version locks consumers into a specific pre-release build. Consider using a stable release version to allow consumers flexibility and reduce maintenance burden.

🤖 Prompt for AI Agents
In `@packages/drizzle-user-db-schemas/package.json` at line 13, The package.json
pins the peer dependency "drizzle-orm" to a specific pre-release
("1.0.0-beta.10-4a43a22"); change the "drizzle-orm" entry in package.json to a
stable or flexible semver range (e.g., "^1.0.0" or ">=0.23.13 <2.0.0") instead
of the specific beta, save the file, and regenerate lockfiles (npm/yarn/pnpm) so
consumers aren't forced to that pre-release; update the
peerDependencies/devDependencies entries referencing "drizzle-orm" if present
and run your install to verify no conflicts.

Comment thread packages/web-ui/src/components/scroll-area.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/web/src/components/features/dictionary/filters/DictionaryFilters.tsx`:
- Around line 74-97: The TagPill onClick handler in DictionaryFilters is using
the render-time variable filteredTags, which can be stale; update the navigate
search updater to compute the new tags from prev.tags instead (inside the
search: (prev) => { ... }) and perform the toggle logic there (check if
prev.tags includes the clicked tag, filter it out or append it accordingly), so
updates are atomic and avoid lost toggles when clicking tags rapidly.

In `@apps/web/src/components/features/dictionary/filters/TagsFilter.tsx`:
- Around line 70-90: The onSelect handler for tag toggles currently reads
filteredTags from render-time closures and then calls navigate with a search
updater that uses that stale value; instead, move the add/remove logic into the
navigate search updater so you compute new tags from prev.tags at call time (use
prev.tags to determine whether to remove or add the current tag variable), then
return the updated tags (or undefined when empty). Update the handler around the
existing navigate(...) call used in TagsFilter.tsx to reference prev.tags inside
the search function rather than using filteredTags captured earlier.
♻️ Duplicate comments (1)
packages/web-ui/src/components/scroll-area.tsx (1)

23-43: Fix incorrect Radix ScrollArea primitive names.

This issue has already been identified in a previous review. The code uses non-existent exports ScrollAreaPrimitive.ScrollAreaScrollbar and ScrollAreaPrimitive.ScrollAreaThumb. The correct exports in @radix-ui/react-scroll-area are Scrollbar and Thumb.

🧹 Nitpick comments (1)
apps/web/src/components/features/dictionary/filters/TagsFilter.tsx (1)

30-33: Surface tag-query failures instead of silently showing the empty state.

useQuery errors currently render the same UI as “no tags,” which can mask DB/query failures. Consider handling isError/error and showing a DisplayError (or an inline fallback) so failures are visible.

Comment on lines +74 to +97
<TagPill
key={tag}
onClick={() => {
const shouldRemove = filteredTags?.some((t) => t === tag);

if (shouldRemove) {
const newTags = filteredTags?.filter((t) => t !== tag);

navigate({
to: "/",
search: (prev) => ({
...prev,
tags: newTags?.length ? newTags : undefined,
}),
});
} else {
navigate({
to: "/",
search: (prev) => ({
...prev,
tags: [...(filteredTags ?? []), tag],
}),
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/components/features/dictionary/filters/DictionaryFilters.tsx

Repository: Shunseii/bahar

Length of output: 6473


🏁 Script executed:

# Search for other uses of navigate with search updaters in the codebase
rg -A 8 'navigate\(' apps/web/src --type tsx --type ts | head -100

Repository: Shunseii/bahar

Length of output: 85


🏁 Script executed:

# Search for navigate with search updater patterns in the codebase
rg -A 8 'navigate\(' apps/web/src | grep -A 8 'search:' | head -80

Repository: Shunseii/bahar

Length of output: 4208


🏁 Script executed:

# Check TagsFilter.tsx to see if it has similar pattern and context
cat -n apps/web/src/components/features/dictionary/filters/TagsFilter.tsx | head -150

Repository: Shunseii/bahar

Length of output: 4266


🏁 Script executed:

# Look for TanstackRouter documentation or check if there are any other patterns
# that might show how the search updater works
rg 'search:.*prev.*=>' apps/web/src -A 5 | head -60

Repository: Shunseii/bahar

Length of output: 3297


Use prev.tags inside the search updater to avoid stale tag toggles.

The handler captures render-time filteredTags which can be stale if multiple tag clicks happen quickly. Computing from prev.tags ensures atomic updates.

✅ Proposed fix
-                  onClick={() => {
-                    const shouldRemove = filteredTags?.some((t) => t === tag);
-
-                    if (shouldRemove) {
-                      const newTags = filteredTags?.filter((t) => t !== tag);
-
-                      navigate({
-                        to: "/",
-                        search: (prev) => ({
-                          ...prev,
-                          tags: newTags?.length ? newTags : undefined,
-                        }),
-                      });
-                    } else {
-                      navigate({
-                        to: "/",
-                        search: (prev) => ({
-                          ...prev,
-                          tags: [...(filteredTags ?? []), tag],
-                        }),
-                      });
-                    }
-                  }}
+                  onClick={() => {
+                    navigate({
+                      to: "/",
+                      search: (prev) => {
+                        const prevTags = prev.tags ?? [];
+                        const nextTags = prevTags.includes(tag)
+                          ? prevTags.filter((t) => t !== tag)
+                          : [...prevTags, tag];
+
+                        return {
+                          ...prev,
+                          tags: nextTags.length ? nextTags : undefined,
+                        };
+                      },
+                    });
+                  }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<TagPill
key={tag}
onClick={() => {
const shouldRemove = filteredTags?.some((t) => t === tag);
if (shouldRemove) {
const newTags = filteredTags?.filter((t) => t !== tag);
navigate({
to: "/",
search: (prev) => ({
...prev,
tags: newTags?.length ? newTags : undefined,
}),
});
} else {
navigate({
to: "/",
search: (prev) => ({
...prev,
tags: [...(filteredTags ?? []), tag],
}),
});
}
<TagPill
key={tag}
onClick={() => {
navigate({
to: "/",
search: (prev) => {
const prevTags = prev.tags ?? [];
const nextTags = prevTags.includes(tag)
? prevTags.filter((t) => t !== tag)
: [...prevTags, tag];
return {
...prev,
tags: nextTags.length ? nextTags : undefined,
};
},
});
}}
🤖 Prompt for AI Agents
In `@apps/web/src/components/features/dictionary/filters/DictionaryFilters.tsx`
around lines 74 - 97, The TagPill onClick handler in DictionaryFilters is using
the render-time variable filteredTags, which can be stale; update the navigate
search updater to compute the new tags from prev.tags instead (inside the
search: (prev) => { ... }) and perform the toggle logic there (check if
prev.tags includes the clicked tag, filter it out or append it accordingly), so
updates are atomic and avoid lost toggles when clicking tags rapidly.

Comment on lines +70 to +90
onSelect={() => {
const shouldRemove = filteredTags?.some((t) => t === tag);

if (shouldRemove) {
const newTags = filteredTags?.filter((t) => t !== tag);

navigate({
to: "/",
search: (prev) => ({
...prev,
tags: newTags?.length ? newTags : undefined,
}),
});
} else {
navigate({
to: "/",
search: (prev) => ({
...prev,
tags: [...(filteredTags ?? []), tag],
}),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/components/features/dictionary/filters/TagsFilter.tsx

Repository: Shunseii/bahar

Length of output: 4266


Compute tag changes from router state in updater to avoid stale closures with rapid toggles.

The onSelect handler captures filteredTags at render time, then uses it inside the search updater. With rapid clicks, multiple navigate calls queue before the router updates, so later calls operate on stale closure values. Move the tag addition/removal logic inside the search updater function to compute from prev.tags instead.

Proposed fix
-                  onSelect={() => {
-                    const shouldRemove = filteredTags?.some((t) => t === tag);
-
-                    if (shouldRemove) {
-                      const newTags = filteredTags?.filter((t) => t !== tag);
-
-                      navigate({
-                        to: "/",
-                        search: (prev) => ({
-                          ...prev,
-                          tags: newTags?.length ? newTags : undefined,
-                        }),
-                      });
-                    } else {
-                      navigate({
-                        to: "/",
-                        search: (prev) => ({
-                          ...prev,
-                          tags: [...(filteredTags ?? []), tag],
-                        }),
-                      });
-                    }
-                  }}
+                  onSelect={() => {
+                    navigate({
+                      to: "/",
+                      search: (prev) => {
+                        const prevTags = prev.tags ?? [];
+                        const nextTags = prevTags.includes(tag)
+                          ? prevTags.filter((t) => t !== tag)
+                          : [...prevTags, tag];
+
+                        return {
+                          ...prev,
+                          tags: nextTags.length ? nextTags : undefined,
+                        };
+                      },
+                    });
+                  }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onSelect={() => {
const shouldRemove = filteredTags?.some((t) => t === tag);
if (shouldRemove) {
const newTags = filteredTags?.filter((t) => t !== tag);
navigate({
to: "/",
search: (prev) => ({
...prev,
tags: newTags?.length ? newTags : undefined,
}),
});
} else {
navigate({
to: "/",
search: (prev) => ({
...prev,
tags: [...(filteredTags ?? []), tag],
}),
});
onSelect={() => {
navigate({
to: "/",
search: (prev) => {
const prevTags = prev.tags ?? [];
const nextTags = prevTags.includes(tag)
? prevTags.filter((t) => t !== tag)
: [...prevTags, tag];
return {
...prev,
tags: nextTags.length ? nextTags : undefined,
};
},
});
}}
🤖 Prompt for AI Agents
In `@apps/web/src/components/features/dictionary/filters/TagsFilter.tsx` around
lines 70 - 90, The onSelect handler for tag toggles currently reads filteredTags
from render-time closures and then calls navigate with a search updater that
uses that stale value; instead, move the add/remove logic into the navigate
search updater so you compute new tags from prev.tags at call time (use
prev.tags to determine whether to remove or add the current tag variable), then
return the updated tags (or undefined when empty). Update the handler around the
existing navigate(...) call used in TagsFilter.tsx to reference prev.tags inside
the search function rather than using filteredTags captured earlier.

@Shunseii Shunseii merged commit 40d2c19 into main Jan 19, 2026
1 of 2 checks passed
@Shunseii Shunseii deleted the feat/tags-filter-and-sorting branch January 19, 2026 13:42
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/components/search/InfiniteScroll.tsx (2)

76-80: Wrap clipboard operations in try/catch to handle permission failures and unsupported contexts.

navigator.clipboard.writeText can reject without proper permissions or in sandboxed contexts, leading to unhandled promise rejections. Add error handling with DisplayError to provide user feedback on failure.

Suggested update
  const handleCopy = async () => {
-   await navigator.clipboard.writeText(text);
-   setCopied(true);
-   setTimeout(() => setCopied(false), 1500);
+   try {
+     await navigator.clipboard.writeText(text);
+     setCopied(true);
+     setTimeout(() => setCopied(false), 1500);
+   } catch (error) {
+     setCopied(false);
+     // surface a DisplayError/toast for copy failures
+   }
  };

470-474: Include hasMore and showMore in the effect dependencies.

The effect uses both hasMore and showMore but only depends on shouldLoadMore. If hasMore becomes true while shouldLoadMore stays true, the effect won't re-run and infinite scrolling will stall. Since showMore is already memoized via useCallback, adding these dependencies won't cause infinite loops.

🧩 Suggested update
-  }, [shouldLoadMore]);
+  }, [shouldLoadMore, hasMore, showMore]);
🤖 Fix all issues with AI agents
In `@apps/web/src/components/search/InfiniteScroll.tsx`:
- Around line 54-69: The SecondaryIconButton component currently renders
icon-only buttons without accessible labels; update its props to accept an
ariaLabel: string (e.g., add ariaLabel?: string to SecondaryIconButton's props)
and set that value on the rendered Button as aria-label={ariaLabel}; then update
all callers (copy/edit/expand toggles) to pass localized labels from
Lingui—providing distinct labels for states like expanded vs collapsed—and
ensure any optional onClick usage still works when ariaLabel is provided.

Comment on lines +54 to +69
const SecondaryIconButton = ({
onClick,
children,
}: {
onClick?: () => void;
children: ReactNode;
}) => {
return (
<Button
className="h-8 w-8 text-muted-foreground hover:bg-primary/10 hover:text-foreground"
onClick={onClick}
size="icon"
variant="ghost"
>
{children}
</Button>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/components/search/InfiniteScroll.tsx | head -100

Repository: Shunseii/bahar

Length of output: 3094


🏁 Script executed:

wc -l apps/web/src/components/search/InfiniteScroll.tsx

Repository: Shunseii/bahar

Length of output: 111


🏁 Script executed:

rg "ariaLabel|aria-label|useLingui" apps/web/src/components/search/InfiniteScroll.tsx

Repository: Shunseii/bahar

Length of output: 171


🏁 Script executed:

cat -n apps/web/src/components/search/InfiniteScroll.tsx | sed -n '54,90p'

Repository: Shunseii/bahar

Length of output: 1137


🏁 Script executed:

cat -n apps/web/src/components/search/InfiniteScroll.tsx | sed -n '345,387p'

Repository: Shunseii/bahar

Length of output: 1945


🏁 Script executed:

rg "SecondaryIconButton|<Button" apps/web/src/components/search/InfiniteScroll.tsx -A 2

Repository: Shunseii/bahar

Length of output: 767


🏁 Script executed:

grep -n "clipboard\|error" apps/web/src/components/search/InfiniteScroll.tsx

Repository: Shunseii/bahar

Length of output: 107


Add accessible labels for icon-only actions.

Icon-only buttons (copy/edit/expand) have no aria-label, so screen readers cannot announce the action. Add an ariaLabel prop to SecondaryIconButton and pass localized labels from callers (including expanded/collapsed state).

♿ Suggested update
-const SecondaryIconButton = ({
-  onClick,
-  children,
-}: {
-  onClick?: () => void;
-  children: ReactNode;
-}) => {
+const SecondaryIconButton = ({
+  onClick,
+  children,
+  ariaLabel,
+}: {
+  onClick?: () => void;
+  children: ReactNode;
+  ariaLabel: string;
+}) => {
   return (
     <Button
+      aria-label={ariaLabel}
       className="h-8 w-8 text-muted-foreground hover:bg-primary/10 hover:text-foreground"
       onClick={onClick}
       size="icon"
       variant="ghost"
     >
       {children}
     </Button>
   );
 };

 const CopyButton: FC<{ text: string }> = memo(({ text }) => {
+  const { t } = useLingui();
   const [copied, setCopied] = useState(false);
@@
-    <SecondaryIconButton onClick={handleCopy}>
+    <SecondaryIconButton onClick={handleCopy} ariaLabel={t`Copy word`}>
       {copied ? (
         <Check className="h-4 w-4 text-green-500" />
       ) : (
         <Copy className="h-4 w-4" />
       )}
     </SecondaryIconButton>
   );
 });

 const WordCardContent: FC<WordCardContentProps> = memo(
   ({ hit, isExpanded, onToggleExpanded, onNavigateEdit }) => {
+    const { t } = useLingui();
@@
-            <SecondaryIconButton onClick={handleEdit}>
+            <SecondaryIconButton onClick={handleEdit} ariaLabel={t`Edit word`}>
               <Edit className="h-4 w-4" />
             </SecondaryIconButton>
@@
-            <SecondaryIconButton onClick={handleToggle}>
+            <SecondaryIconButton
+              onClick={handleToggle}
+              ariaLabel={isExpanded ? t`Collapse details` : t`Expand details`}
+            >
               <motion.div
                 animate={{ rotate: isExpanded ? 180 : 0 }}
                 transition={{ duration: 0.2 }}
               >

Use Lingui for user-facing strings, including aria-labels, as per coding guidelines.

🤖 Prompt for AI Agents
In `@apps/web/src/components/search/InfiniteScroll.tsx` around lines 54 - 69, The
SecondaryIconButton component currently renders icon-only buttons without
accessible labels; update its props to accept an ariaLabel: string (e.g., add
ariaLabel?: string to SecondaryIconButton's props) and set that value on the
rendered Button as aria-label={ariaLabel}; then update all callers
(copy/edit/expand toggles) to pass localized labels from Lingui—providing
distinct labels for states like expanded vs collapsed—and ensure any optional
onClick usage still works when ariaLabel is provided.

This was referenced Apr 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant