diff --git a/src/components/blog/PostSorting.tsx b/src/components/blog/PostSorting.tsx index d12c1c0..6aa302b 100644 --- a/src/components/blog/PostSorting.tsx +++ b/src/components/blog/PostSorting.tsx @@ -16,59 +16,38 @@ export interface PostSortingProps { } export default function PostSorting(props: PostSortingProps) { - // Build set of tags that are ALLOWED - const allowedTags = createMemo(() => { - // WHITELIST MODE: If 'include' param is present, only show posts with those tags - if (props.include) { - const includeList = props.include.split("|").filter(Boolean); - return new Set(includeList); - } - - // 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(); - allTags.forEach((tag) => { - if (!filteredOutTags.has(tag)) { - allowed.add(tag); + const filteredPosts = createMemo(() => { + // Build map of post_id -> tags for that post + const postTags = new Map>(); + props.tags.forEach((tag) => { + if (!postTags.has(tag.post_id)) { + postTags.set(tag.post_id, new Set()); } + // 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 - const filteredPosts = createMemo(() => { - const allowed = allowedTags(); + // Empty whitelist means show nothing + if (includeList.length === 0) { + return []; + } - // In whitelist mode, only show posts with allowed tags - if (props.include) { - // Build map of post_id -> tags for that post - const postTags = new Map>(); - 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)); - }); + const includeSet = new Set(includeList); - // 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 + 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) { - if (allowed.has(tag)) { + if (includeSet.has(tag)) { return true; } } @@ -76,38 +55,33 @@ export default function PostSorting(props: PostSortingProps) { }); } - // In blacklist mode, show all posts if all tags are allowed - if ( - allowed.size === - props.tags - .map((t) => t.value.slice(1)) - .filter((v, i, a) => a.indexOf(v) === i).length - ) { - return props.posts; + // BLACKLIST MODE: Hide posts that have ANY of the filtered tags + if (props.filters !== undefined) { + const filterList = props.filters.split("|").filter(Boolean); + + // Empty blacklist means show everything + if (filterList.length === 0) { + 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 - const postTags = new Map>(); - 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; - }); + // No filters: show all posts + return props.posts; }); const sortedPosts = createMemo(() => { diff --git a/src/components/blog/PostSortingSelect.tsx b/src/components/blog/PostSortingSelect.tsx index 9586ea7..a933477 100644 --- a/src/components/blog/PostSortingSelect.tsx +++ b/src/components/blog/PostSortingSelect.tsx @@ -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 Check from "~/components/icons/Check"; import UpDownArrows from "~/components/icons/UpDownArrows"; @@ -14,29 +14,25 @@ const sorting = [ export interface PostSortingSelectProps {} export default function PostSortingSelect(props: PostSortingSelectProps) { - const [selected, setSelected] = createSignal(sorting[0]); const [isOpen, setIsOpen] = createSignal(false); const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); - const currentFilters = () => searchParams.filter || null; - const currentInclude = () => searchParams.include || null; - - createEffect(() => { - let newRoute = location.pathname + "?sort=" + selected().val; - if (currentFilters()) { - newRoute += "&filter=" + currentFilters(); - } - if (currentInclude()) { - newRoute += "&include=" + currentInclude(); - } - navigate(newRoute); - }); + // Derive selected from URL params instead of local state + const selected = () => { + const sortParam = searchParams.sort || "newest"; + return sorting.find((s) => s.val === sortParam) || sorting[0]; + }; const handleSelect = (sort: { val: string; label: string }) => { - setSelected(sort); setIsOpen(false); + + // Build new URL preserving all existing params + const params = new URLSearchParams(searchParams as Record); + params.set("sort", sort.val); + + navigate(`${location.pathname}?${params.toString()}`); }; return ( diff --git a/src/components/blog/TagSelector.tsx b/src/components/blog/TagSelector.tsx index 45ca74d..b200827 100644 --- a/src/components/blog/TagSelector.tsx +++ b/src/components/blog/TagSelector.tsx @@ -24,18 +24,33 @@ export default function TagSelector(props: TagSelectorProps) { const location = useLocation(); const [searchParams] = useSearchParams(); - const currentSort = () => searchParams.sort || ""; const currentFilters = () => searchParams.filter?.split("|").filter(Boolean) || []; const currentInclude = () => 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(() => { - if (searchParams.include) { + if ("include" in searchParams) { setFilterMode("whitelist"); - } else if (searchParams.filter) { + } else if ("filter" in searchParams) { setFilterMode("blacklist"); + } else { + // No filter param exists, default to blacklist mode with empty filter + const params = new URLSearchParams( + searchParams as Record + ); + 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)) ); - // In blacklist mode: checked = not filtered out - // In whitelist mode: checked = included in whitelist + // Check if a tag is currently selected const isTagChecked = (tag: string) => { - if (filterMode() === "whitelist") { - return currentInclude().includes(tag); - } else { - return !currentFilters().includes(tag); - } + return selectedTags().includes(tag); }; const allChecked = createMemo(() => { - if (filterMode() === "whitelist") { - return currentInclude().length === allTagKeys().length; - } else { - return allTagKeys().every((tag) => !currentFilters().includes(tag)); - } + return ( + selectedTags().length === allTagKeys().length && allTagKeys().length > 0 + ); }); const handleClickOutside = (e: MouseEvent) => { @@ -94,84 +102,80 @@ export default function TagSelector(props: TagSelectorProps) { }; const handleCheck = (tag: string, isChecked: boolean) => { - if (filterMode() === "whitelist") { - // Whitelist mode: manage include param - let newInclude: string[]; - if (isChecked) { - // Add to whitelist - newInclude = [...currentInclude(), tag]; - } else { - // Remove from whitelist - newInclude = currentInclude().filter((t) => t !== tag); - } + const currentSelected = selectedTags(); + let newSelected: string[]; - if (newInclude.length > 0) { - const includeStr = newInclude.map((t) => `#${t}`).join("|"); - navigate( - `${location.pathname}?sort=${currentSort()}&include=${includeStr}` - ); - } else { - // If no tags selected, clear whitelist - navigate(`${location.pathname}?sort=${currentSort()}`); - } + if (isChecked) { + // Add tag to selection + newSelected = [...currentSelected, tag]; } else { - // Blacklist mode: manage filter param - if (isChecked) { - 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}|`); - } - } + // Remove tag from selection + newSelected = currentSelected.filter((t) => t !== tag); } + + // Build URL preserving all existing params + const params = new URLSearchParams(searchParams as Record); + 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 = () => { - if (filterMode() === "whitelist") { - if (allChecked()) { - // Uncheck all: clear whitelist - navigate(`${location.pathname}?sort=${currentSort()}`); - } else { - // Check all: add all tags to whitelist - const allTags = allTagKeys() - .map((t) => `#${t}`) - .join("|"); - navigate( - `${location.pathname}?sort=${currentSort()}&include=${allTags}` - ); - } + const params = new URLSearchParams(searchParams as Record); + const paramName = filterMode() === "whitelist" ? "include" : "filter"; + const otherParamName = filterMode() === "whitelist" ? "filter" : "include"; + + // Remove the other mode's param + params.delete(otherParamName); + + if (allChecked()) { + // Uncheck all: keep empty param to preserve mode + params.set(paramName, ""); } else { - if (allChecked()) { - // Uncheck all: Build filter string with all tags - const allTags = allTagKeys().join("|") + "|"; - navigate( - `${location.pathname}?sort=${currentSort()}&filter=${allTags}` - ); - } else { - // Check all: Remove filter param - navigate(`${location.pathname}?sort=${currentSort()}`); - } + // Check all: select all tags + const allTags = allTagKeys().join("|"); + params.set(paramName, allTags); } + + navigate(`${location.pathname}?${params.toString()}`); }; const toggleFilterMode = () => { + // Get current tags BEFORE changing mode + const currentSelected = selectedTags(); + const newMode = filterMode() === "whitelist" ? "blacklist" : "whitelist"; 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); + 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 ( @@ -209,8 +213,8 @@ export default function TagSelector(props: TagSelectorProps) {
{filterMode() === "whitelist" - ? "Check tags to show ONLY those posts" - : "Uncheck tags to HIDE those posts"} + ? "Check tags to show ONLY posts with those tags" + : "Check tags to HIDE posts with those tags"}
diff --git a/src/routes/blog/index.tsx b/src/routes/blog/index.tsx index 9a53318..1b62a11 100644 --- a/src/routes/blog/index.tsx +++ b/src/routes/blog/index.tsx @@ -76,8 +76,10 @@ export default function BlogIndex() { const [searchParams] = useSearchParams(); const sort = () => searchParams.sort || "newest"; - const filters = () => searchParams.filter || ""; - const include = () => searchParams.include || ""; + const filters = () => + "filter" in searchParams ? searchParams.filter : undefined; + const include = () => + "include" in searchParams ? searchParams.include : undefined; const data = createAsync(() => getPosts(), { deferStream: true });