fixed filtering

This commit is contained in:
Michael Freno
2025-12-23 10:12:36 -05:00
parent 2ebd7840b7
commit ee1de16c9e
4 changed files with 150 additions and 174 deletions

View File

@@ -16,59 +16,38 @@ export interface PostSortingProps {
} }
export default function PostSorting(props: PostSortingProps) { export default function PostSorting(props: PostSortingProps) {
// Build set of tags that are ALLOWED const filteredPosts = createMemo(() => {
const allowedTags = createMemo(() => { // Build map of post_id -> tags for that post
// WHITELIST MODE: If 'include' param is present, only show posts with those tags const postTags = new Map<number, Set<string>>();
if (props.include) { props.tags.forEach((tag) => {
const includeList = props.include.split("|").filter(Boolean); if (!postTags.has(tag.post_id)) {
return new Set(includeList); postTags.set(tag.post_id, new Set());
}
// BLACKLIST MODE: Filter out tags in 'filter' param
const filterList = props.filters?.split("|").filter(Boolean) || [];
// If no filters set, all tags are allowed
if (filterList.length === 0) {
return new Set(props.tags.map((t) => t.value.slice(1)));
}
// Build set of tags that are checked (allowed to show)
const allTags = new Set(props.tags.map((t) => t.value.slice(1)));
const filteredOutTags = new Set(filterList);
const allowed = new Set<string>();
allTags.forEach((tag) => {
if (!filteredOutTags.has(tag)) {
allowed.add(tag);
} }
// Tag values in DB have # prefix, remove it for comparison
const tagWithoutHash = tag.value.startsWith("#")
? tag.value.slice(1)
: tag.value;
postTags.get(tag.post_id)!.add(tagWithoutHash);
}); });
return allowed; // WHITELIST MODE: Only show posts that have at least one of the included tags
}); if (props.include !== undefined) {
const includeList = props.include.split("|").filter(Boolean);
// Get posts that have at least one allowed tag // Empty whitelist means show nothing
const filteredPosts = createMemo(() => { if (includeList.length === 0) {
const allowed = allowedTags(); return [];
}
// In whitelist mode, only show posts with allowed tags const includeSet = new Set(includeList);
if (props.include) {
// Build map of post_id -> tags for that post
const postTags = new Map<number, Set<string>>();
props.tags.forEach((tag) => {
if (!postTags.has(tag.post_id)) {
postTags.set(tag.post_id, new Set());
}
postTags.get(tag.post_id)!.add(tag.value.slice(1));
});
// Keep posts that have at least one allowed tag
return props.posts.filter((post) => { return props.posts.filter((post) => {
const tags = postTags.get(post.id); const tags = postTags.get(post.id);
if (!tags) return false; // Post has no tags if (!tags || tags.size === 0) return false;
// Check if post has at least one allowed tag // Post must have at least one tag from the include list
for (const tag of tags) { for (const tag of tags) {
if (allowed.has(tag)) { if (includeSet.has(tag)) {
return true; return true;
} }
} }
@@ -76,38 +55,33 @@ export default function PostSorting(props: PostSortingProps) {
}); });
} }
// In blacklist mode, show all posts if all tags are allowed // BLACKLIST MODE: Hide posts that have ANY of the filtered tags
if ( if (props.filters !== undefined) {
allowed.size === const filterList = props.filters.split("|").filter(Boolean);
props.tags
.map((t) => t.value.slice(1)) // Empty blacklist means show everything
.filter((v, i, a) => a.indexOf(v) === i).length if (filterList.length === 0) {
) { return props.posts;
return props.posts; }
const filterSet = new Set(filterList);
return props.posts.filter((post) => {
const tags = postTags.get(post.id);
if (!tags || tags.size === 0) return true; // Show posts with no tags
// Post must NOT have any blacklisted tags
for (const tag of tags) {
if (filterSet.has(tag)) {
return false; // Hide this post
}
}
return true; // Show this post
});
} }
// Build map of post_id -> tags for that post // No filters: show all posts
const postTags = new Map<number, Set<string>>(); return props.posts;
props.tags.forEach((tag) => {
if (!postTags.has(tag.post_id)) {
postTags.set(tag.post_id, new Set());
}
postTags.get(tag.post_id)!.add(tag.value.slice(1));
});
// Keep posts that have at least one allowed tag
return props.posts.filter((post) => {
const tags = postTags.get(post.id);
if (!tags) return false; // Post has no tags
// Check if post has at least one allowed tag
for (const tag of tags) {
if (allowed.has(tag)) {
return true;
}
}
return false;
});
}); });
const sortedPosts = createMemo(() => { const sortedPosts = createMemo(() => {

View File

@@ -1,4 +1,4 @@
import { createSignal, createEffect, For, Show } from "solid-js"; import { createSignal, For, Show } from "solid-js";
import { useNavigate, useLocation, useSearchParams } from "@solidjs/router"; import { useNavigate, useLocation, useSearchParams } from "@solidjs/router";
import Check from "~/components/icons/Check"; import Check from "~/components/icons/Check";
import UpDownArrows from "~/components/icons/UpDownArrows"; import UpDownArrows from "~/components/icons/UpDownArrows";
@@ -14,29 +14,25 @@ const sorting = [
export interface PostSortingSelectProps {} export interface PostSortingSelectProps {}
export default function PostSortingSelect(props: PostSortingSelectProps) { export default function PostSortingSelect(props: PostSortingSelectProps) {
const [selected, setSelected] = createSignal(sorting[0]);
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const currentFilters = () => searchParams.filter || null; // Derive selected from URL params instead of local state
const currentInclude = () => searchParams.include || null; const selected = () => {
const sortParam = searchParams.sort || "newest";
createEffect(() => { return sorting.find((s) => s.val === sortParam) || sorting[0];
let newRoute = location.pathname + "?sort=" + selected().val; };
if (currentFilters()) {
newRoute += "&filter=" + currentFilters();
}
if (currentInclude()) {
newRoute += "&include=" + currentInclude();
}
navigate(newRoute);
});
const handleSelect = (sort: { val: string; label: string }) => { const handleSelect = (sort: { val: string; label: string }) => {
setSelected(sort);
setIsOpen(false); setIsOpen(false);
// Build new URL preserving all existing params
const params = new URLSearchParams(searchParams as Record<string, string>);
params.set("sort", sort.val);
navigate(`${location.pathname}?${params.toString()}`);
}; };
return ( return (

View File

@@ -24,18 +24,33 @@ export default function TagSelector(props: TagSelectorProps) {
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const currentSort = () => searchParams.sort || "";
const currentFilters = () => const currentFilters = () =>
searchParams.filter?.split("|").filter(Boolean) || []; searchParams.filter?.split("|").filter(Boolean) || [];
const currentInclude = () => const currentInclude = () =>
searchParams.include?.split("|").filter(Boolean) || []; searchParams.include?.split("|").filter(Boolean) || [];
// Sync filter mode with URL params // Get currently selected tags based on mode
const selectedTags = () => {
if (filterMode() === "whitelist") {
return currentInclude();
} else {
return currentFilters();
}
};
// Sync filter mode with URL params and ensure one is always present
createEffect(() => { createEffect(() => {
if (searchParams.include) { if ("include" in searchParams) {
setFilterMode("whitelist"); setFilterMode("whitelist");
} else if (searchParams.filter) { } else if ("filter" in searchParams) {
setFilterMode("blacklist"); setFilterMode("blacklist");
} else {
// No filter param exists, default to blacklist mode with empty filter
const params = new URLSearchParams(
searchParams as Record<string, string>
);
params.set("filter", "");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
} }
}); });
@@ -51,22 +66,15 @@ export default function TagSelector(props: TagSelectorProps) {
Object.keys(props.tagMap).map((key) => key.slice(1)) Object.keys(props.tagMap).map((key) => key.slice(1))
); );
// In blacklist mode: checked = not filtered out // Check if a tag is currently selected
// In whitelist mode: checked = included in whitelist
const isTagChecked = (tag: string) => { const isTagChecked = (tag: string) => {
if (filterMode() === "whitelist") { return selectedTags().includes(tag);
return currentInclude().includes(tag);
} else {
return !currentFilters().includes(tag);
}
}; };
const allChecked = createMemo(() => { const allChecked = createMemo(() => {
if (filterMode() === "whitelist") { return (
return currentInclude().length === allTagKeys().length; selectedTags().length === allTagKeys().length && allTagKeys().length > 0
} else { );
return allTagKeys().every((tag) => !currentFilters().includes(tag));
}
}); });
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
@@ -94,84 +102,80 @@ export default function TagSelector(props: TagSelectorProps) {
}; };
const handleCheck = (tag: string, isChecked: boolean) => { const handleCheck = (tag: string, isChecked: boolean) => {
if (filterMode() === "whitelist") { const currentSelected = selectedTags();
// Whitelist mode: manage include param let newSelected: string[];
let newInclude: string[];
if (isChecked) {
// Add to whitelist
newInclude = [...currentInclude(), tag];
} else {
// Remove from whitelist
newInclude = currentInclude().filter((t) => t !== tag);
}
if (newInclude.length > 0) { if (isChecked) {
const includeStr = newInclude.map((t) => `#${t}`).join("|"); // Add tag to selection
navigate( newSelected = [...currentSelected, tag];
`${location.pathname}?sort=${currentSort()}&include=${includeStr}`
);
} else {
// If no tags selected, clear whitelist
navigate(`${location.pathname}?sort=${currentSort()}`);
}
} else { } else {
// Blacklist mode: manage filter param // Remove tag from selection
if (isChecked) { newSelected = currentSelected.filter((t) => t !== tag);
const newFilters = searchParams.filter?.replace(tag + "|", "");
if (newFilters && newFilters.length >= 1) {
navigate(
`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`
);
} else {
navigate(`${location.pathname}?sort=${currentSort()}`);
}
} else {
const currentFiltersStr = searchParams.filter;
if (currentFiltersStr) {
const newFilters = currentFiltersStr + tag + "|";
navigate(
`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`
);
} else {
navigate(`${location.pathname}?sort=${currentSort()}&filter=${tag}|`);
}
}
} }
// Build URL preserving all existing params
const params = new URLSearchParams(searchParams as Record<string, string>);
const paramName = filterMode() === "whitelist" ? "include" : "filter";
const otherParamName = filterMode() === "whitelist" ? "filter" : "include";
// Remove the other mode's param
params.delete(otherParamName);
if (newSelected.length > 0) {
const paramValue = newSelected.join("|");
params.set(paramName, paramValue);
} else {
// Keep empty param to preserve mode (especially important for whitelist)
params.set(paramName, "");
}
navigate(`${location.pathname}?${params.toString()}`);
}; };
const handleToggleAll = () => { const handleToggleAll = () => {
if (filterMode() === "whitelist") { const params = new URLSearchParams(searchParams as Record<string, string>);
if (allChecked()) { const paramName = filterMode() === "whitelist" ? "include" : "filter";
// Uncheck all: clear whitelist const otherParamName = filterMode() === "whitelist" ? "filter" : "include";
navigate(`${location.pathname}?sort=${currentSort()}`);
} else { // Remove the other mode's param
// Check all: add all tags to whitelist params.delete(otherParamName);
const allTags = allTagKeys()
.map((t) => `#${t}`) if (allChecked()) {
.join("|"); // Uncheck all: keep empty param to preserve mode
navigate( params.set(paramName, "");
`${location.pathname}?sort=${currentSort()}&include=${allTags}`
);
}
} else { } else {
if (allChecked()) { // Check all: select all tags
// Uncheck all: Build filter string with all tags const allTags = allTagKeys().join("|");
const allTags = allTagKeys().join("|") + "|"; params.set(paramName, allTags);
navigate(
`${location.pathname}?sort=${currentSort()}&filter=${allTags}`
);
} else {
// Check all: Remove filter param
navigate(`${location.pathname}?sort=${currentSort()}`);
}
} }
navigate(`${location.pathname}?${params.toString()}`);
}; };
const toggleFilterMode = () => { const toggleFilterMode = () => {
// Get current tags BEFORE changing mode
const currentSelected = selectedTags();
const newMode = filterMode() === "whitelist" ? "blacklist" : "whitelist"; const newMode = filterMode() === "whitelist" ? "blacklist" : "whitelist";
setFilterMode(newMode); setFilterMode(newMode);
// Clear all filters when switching modes
navigate(`${location.pathname}?sort=${currentSort()}`); // Keep the same selected tags, just change the param name
const params = new URLSearchParams(searchParams as Record<string, string>);
const newParamName = newMode === "whitelist" ? "include" : "filter";
const oldParamName = newMode === "whitelist" ? "filter" : "include";
// Remove old param and set new one
params.delete(oldParamName);
if (currentSelected.length > 0) {
const paramValue = currentSelected.join("|");
params.set(newParamName, paramValue);
} else {
// Always keep the param, even if empty
params.set(newParamName, "");
}
navigate(`${location.pathname}?${params.toString()}`);
}; };
return ( return (
@@ -209,8 +213,8 @@ export default function TagSelector(props: TagSelectorProps) {
</div> </div>
<div class="text-subtext1 text-xs italic"> <div class="text-subtext1 text-xs italic">
{filterMode() === "whitelist" {filterMode() === "whitelist"
? "Check tags to show ONLY those posts" ? "Check tags to show ONLY posts with those tags"
: "Uncheck tags to HIDE those posts"} : "Check tags to HIDE posts with those tags"}
</div> </div>
</div> </div>

View File

@@ -76,8 +76,10 @@ export default function BlogIndex() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const sort = () => searchParams.sort || "newest"; const sort = () => searchParams.sort || "newest";
const filters = () => searchParams.filter || ""; const filters = () =>
const include = () => searchParams.include || ""; "filter" in searchParams ? searchParams.filter : undefined;
const include = () =>
"include" in searchParams ? searchParams.include : undefined;
const data = createAsync(() => getPosts(), { deferStream: true }); const data = createAsync(() => getPosts(), { deferStream: true });