This commit is contained in:
Michael Freno
2025-12-23 09:35:55 -05:00
parent be3e9a9e0b
commit 2ebd7840b7
8 changed files with 235 additions and 140 deletions

View File

@@ -173,7 +173,6 @@ function AppLayout(props: { children: any }) {
</div> </div>
</noscript> </noscript>
<div <div
class="py-16"
onMouseUp={handleCenterTapRelease} onMouseUp={handleCenterTapRelease}
onTouchEnd={handleCenterTapRelease} onTouchEnd={handleCenterTapRelease}
> >

View File

@@ -12,11 +12,19 @@ export interface PostSortingProps {
privilegeLevel: "anonymous" | "admin" | "user"; privilegeLevel: "anonymous" | "admin" | "user";
filters?: string; filters?: string;
sort?: string; sort?: string;
include?: string;
} }
export default function PostSorting(props: PostSortingProps) { export default function PostSorting(props: PostSortingProps) {
// Build set of tags that are ALLOWED (not filtered out) // Build set of tags that are ALLOWED
const allowedTags = createMemo(() => { 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) || []; const filterList = props.filters?.split("|").filter(Boolean) || [];
// If no filters set, all tags are allowed // If no filters set, all tags are allowed
@@ -42,7 +50,33 @@ export default function PostSorting(props: PostSortingProps) {
const filteredPosts = createMemo(() => { const filteredPosts = createMemo(() => {
const allowed = allowedTags(); const allowed = allowedTags();
// If all tags are allowed, show all posts // 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<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) => {
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;
});
}
// In blacklist mode, show all posts if all tags are allowed
if ( if (
allowed.size === allowed.size ===
props.tags props.tags

View File

@@ -21,12 +21,16 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const currentFilters = () => searchParams.filter || null; const currentFilters = () => searchParams.filter || null;
const currentInclude = () => searchParams.include || null;
createEffect(() => { createEffect(() => {
let newRoute = location.pathname + "?sort=" + selected().val; let newRoute = location.pathname + "?sort=" + selected().val;
if (currentFilters()) { if (currentFilters()) {
newRoute += "&filter=" + currentFilters(); newRoute += "&filter=" + currentFilters();
} }
if (currentInclude()) {
newRoute += "&include=" + currentInclude();
}
navigate(newRoute); navigate(newRoute);
}); });

View File

@@ -15,6 +15,9 @@ export interface TagSelectorProps {
export default function TagSelector(props: TagSelectorProps) { export default function TagSelector(props: TagSelectorProps) {
const [showingMenu, setShowingMenu] = createSignal(false); const [showingMenu, setShowingMenu] = createSignal(false);
const [showingRareTags, setShowingRareTags] = createSignal(false); const [showingRareTags, setShowingRareTags] = createSignal(false);
const [filterMode, setFilterMode] = createSignal<"whitelist" | "blacklist">(
"blacklist"
);
let buttonRef: HTMLButtonElement | undefined; let buttonRef: HTMLButtonElement | undefined;
let menuRef: HTMLDivElement | undefined; let menuRef: HTMLDivElement | undefined;
const navigate = useNavigate(); const navigate = useNavigate();
@@ -22,7 +25,19 @@ export default function TagSelector(props: TagSelectorProps) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const currentSort = () => searchParams.sort || ""; const currentSort = () => searchParams.sort || "";
const currentFilters = () => searchParams.filter?.split("|") || []; const currentFilters = () =>
searchParams.filter?.split("|").filter(Boolean) || [];
const currentInclude = () =>
searchParams.include?.split("|").filter(Boolean) || [];
// Sync filter mode with URL params
createEffect(() => {
if (searchParams.include) {
setFilterMode("whitelist");
} else if (searchParams.filter) {
setFilterMode("blacklist");
}
});
const frequentTags = createMemo(() => const frequentTags = createMemo(() =>
Object.entries(props.tagMap).filter(([_, count]) => count > 1) Object.entries(props.tagMap).filter(([_, count]) => count > 1)
@@ -36,9 +51,23 @@ export default function TagSelector(props: TagSelectorProps) {
Object.keys(props.tagMap).map((key) => key.slice(1)) Object.keys(props.tagMap).map((key) => key.slice(1))
); );
const allChecked = createMemo(() => // In blacklist mode: checked = not filtered out
allTagKeys().every((tag) => !currentFilters().includes(tag)) // In whitelist mode: checked = included in whitelist
); const isTagChecked = (tag: string) => {
if (filterMode() === "whitelist") {
return currentInclude().includes(tag);
} else {
return !currentFilters().includes(tag);
}
};
const allChecked = createMemo(() => {
if (filterMode() === "whitelist") {
return currentInclude().length === allTagKeys().length;
} else {
return allTagKeys().every((tag) => !currentFilters().includes(tag));
}
});
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if ( if (
@@ -64,9 +93,31 @@ export default function TagSelector(props: TagSelectorProps) {
setShowingMenu(!showingMenu()); setShowingMenu(!showingMenu());
}; };
const handleCheck = (filter: string, isChecked: boolean) => { const handleCheck = (tag: string, isChecked: boolean) => {
if (filterMode() === "whitelist") {
// Whitelist mode: manage include param
let newInclude: string[];
if (isChecked) { if (isChecked) {
const newFilters = searchParams.filter?.replace(filter + "|", ""); // Add to whitelist
newInclude = [...currentInclude(), tag];
} else {
// Remove from whitelist
newInclude = currentInclude().filter((t) => t !== tag);
}
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()}`);
}
} else {
// Blacklist mode: manage filter param
if (isChecked) {
const newFilters = searchParams.filter?.replace(tag + "|", "");
if (newFilters && newFilters.length >= 1) { if (newFilters && newFilters.length >= 1) {
navigate( navigate(
`${location.pathname}?sort=${currentSort()}&filter=${newFilters}` `${location.pathname}?sort=${currentSort()}&filter=${newFilters}`
@@ -77,27 +128,50 @@ export default function TagSelector(props: TagSelectorProps) {
} else { } else {
const currentFiltersStr = searchParams.filter; const currentFiltersStr = searchParams.filter;
if (currentFiltersStr) { if (currentFiltersStr) {
const newFilters = currentFiltersStr + filter + "|"; const newFilters = currentFiltersStr + tag + "|";
navigate( navigate(
`${location.pathname}?sort=${currentSort()}&filter=${newFilters}` `${location.pathname}?sort=${currentSort()}&filter=${newFilters}`
); );
} else { } else {
navigate( navigate(`${location.pathname}?sort=${currentSort()}&filter=${tag}|`);
`${location.pathname}?sort=${currentSort()}&filter=${filter}|` }
);
} }
} }
}; };
const handleToggleAll = () => { 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}`
);
}
} else {
if (allChecked()) { if (allChecked()) {
// Uncheck all: Build filter string with all tags // Uncheck all: Build filter string with all tags
const allTags = allTagKeys().join("|") + "|"; const allTags = allTagKeys().join("|") + "|";
navigate(`${location.pathname}?sort=${currentSort()}&filter=${allTags}`); navigate(
`${location.pathname}?sort=${currentSort()}&filter=${allTags}`
);
} else { } else {
// Check all: Remove filter param // Check all: Remove filter param
navigate(`${location.pathname}?sort=${currentSort()}`); navigate(`${location.pathname}?sort=${currentSort()}`);
} }
}
};
const toggleFilterMode = () => {
const newMode = filterMode() === "whitelist" ? "blacklist" : "whitelist";
setFilterMode(newMode);
// Clear all filters when switching modes
navigate(`${location.pathname}?sort=${currentSort()}`);
}; };
return ( return (
@@ -113,23 +187,51 @@ export default function TagSelector(props: TagSelectorProps) {
<Show when={showingMenu()}> <Show when={showingMenu()}>
<div <div
ref={menuRef} ref={menuRef}
class="bg-surface0 absolute top-full left-0 z-50 mt-2 rounded-lg py-2 pr-4 pl-2 shadow-lg" class="bg-surface0 absolute top-full left-0 z-50 mt-2 min-w-64 rounded-lg py-2 pr-4 pl-2 shadow-lg"
> >
{/* Filter Mode Toggle */}
<div class="border-overlay0 mb-2 border-b pb-2">
<div class="mb-2 flex items-center justify-between">
<span class="text-subtext0 text-xs font-medium">
Filter Mode:
</span>
<button
type="button"
onClick={toggleFilterMode}
class={`rounded px-2 py-1 text-xs font-semibold transition-all duration-200 hover:brightness-110 ${
filterMode() === "whitelist"
? "bg-green text-base"
: "bg-red text-base"
}`}
>
{filterMode() === "whitelist" ? "✓ Whitelist" : "✗ Blacklist"}
</button>
</div>
<div class="text-subtext1 text-xs italic">
{filterMode() === "whitelist"
? "Check tags to show ONLY those posts"
: "Uncheck tags to HIDE those posts"}
</div>
</div>
{/* Toggle All Button */}
<div class="border-overlay0 mb-2 flex justify-center border-b pb-2"> <div class="border-overlay0 mb-2 flex justify-center border-b pb-2">
<button <button
type="button" type="button"
onClick={handleToggleAll} onClick={handleToggleAll}
class="text-text hover:text-red text-xs font-medium underline" class="text-text hover:text-blue text-xs font-medium underline"
> >
{allChecked() ? "Uncheck All" : "Check All"} {allChecked() ? "Uncheck All" : "Check All"}
</button> </button>
</div> </div>
{/* Frequent Tags */}
<For each={frequentTags()}> <For each={frequentTags()}>
{([key, value]) => ( {([key, value]) => (
<div class="mx-auto my-2 flex"> <div class="mx-auto my-2 flex">
<input <input
type="checkbox" type="checkbox"
checked={!currentFilters().includes(key.slice(1))} checked={isTagChecked(key.slice(1))}
onChange={(e) => onChange={(e) =>
handleCheck(key.slice(1), e.currentTarget.checked) handleCheck(key.slice(1), e.currentTarget.checked)
} }
@@ -140,6 +242,8 @@ export default function TagSelector(props: TagSelectorProps) {
</div> </div>
)} )}
</For> </For>
{/* Rare Tags Section */}
<Show when={rareTags().length > 0}> <Show when={rareTags().length > 0}>
<div class="border-overlay0 mt-2 border-t pt-2"> <div class="border-overlay0 mt-2 border-t pt-2">
<button <button
@@ -155,7 +259,7 @@ export default function TagSelector(props: TagSelectorProps) {
<div class="mx-auto my-2 flex"> <div class="mx-auto my-2 flex">
<input <input
type="checkbox" type="checkbox"
checked={!currentFilters().includes(key.slice(1))} checked={isTagChecked(key.slice(1))}
onChange={(e) => onChange={(e) =>
handleCheck(key.slice(1), e.currentTarget.checked) handleCheck(key.slice(1), e.currentTarget.checked)
} }

View File

@@ -317,11 +317,17 @@ export default function PostPage() {
</div> </div>
<div class="flex max-w-105 flex-wrap justify-center italic md:justify-start md:pl-24"> <div class="flex max-w-105 flex-wrap justify-center italic md:justify-start md:pl-24">
<For each={postData.tags as any[]}> <For each={postData.tags as any[]}>
{(tag) => ( {(tag) => {
<div class="group relative m-1 h-fit w-fit rounded-xl bg-purple-600 px-2 py-1 text-sm"> const tagValue = tag.value;
<div class="text-white">{tag.value}</div> return tagValue ? (
</div> <A
)} href={`/blog?include=${encodeURIComponent(tagValue.split("#")[1])}`}
class="group bg-rosewater relative m-1 h-fit w-fit rounded-xl px-2 py-1 text-sm transition-all duration-200 hover:brightness-110 active:scale-95"
>
<div class="text-white">{tagValue}</div>
</A>
) : null;
}}
</For> </For>
</div> </div>
</div> </div>
@@ -333,7 +339,7 @@ export default function PostPage() {
<Fire <Fire
height={32} height={32}
width={32} width={32}
color="var(--color-text)" color="var(--color-red)"
/> />
</div> </div>
<div class="text-text my-auto pt-0.5 pl-2 text-sm"> <div class="text-text my-auto pt-0.5 pl-2 text-sm">
@@ -344,7 +350,14 @@ export default function PostPage() {
</div> </div>
<a href="#comments" class="mx-2"> <a href="#comments" class="mx-2">
<div class="tooltip flex flex-col"> <button
onClick={() => {
document
.getElementById("comments")
?.scrollIntoView({ behavior: "smooth" });
}}
class="tooltip flex flex-col"
>
<div class="mx-auto hover:brightness-125"> <div class="mx-auto hover:brightness-125">
<CommentIcon <CommentIcon
strokeWidth={1} strokeWidth={1}
@@ -358,7 +371,7 @@ export default function PostPage() {
? "Comment" ? "Comment"
: "Comments"} : "Comments"}
</div> </div>
</div> </button>
</a> </a>
<div class="mx-2"> <div class="mx-2">

View File

@@ -77,6 +77,7 @@ export default function BlogIndex() {
const sort = () => searchParams.sort || "newest"; const sort = () => searchParams.sort || "newest";
const filters = () => searchParams.filter || ""; const filters = () => searchParams.filter || "";
const include = () => searchParams.include || "";
const data = createAsync(() => getPosts(), { deferStream: true }); const data = createAsync(() => getPosts(), { deferStream: true });
@@ -84,7 +85,7 @@ export default function BlogIndex() {
<> <>
<Title>Blog | Michael Freno</Title> <Title>Blog | Michael Freno</Title>
<div class="mx-auto pt-8 pb-24"> <div class="mx-auto py-16 pb-24">
<Show when={data()} fallback={<TerminalSplash />}> <Show when={data()} fallback={<TerminalSplash />}>
{(loadedData) => ( {(loadedData) => (
<> <>
@@ -118,6 +119,7 @@ export default function BlogIndex() {
privilegeLevel={loadedData().privilegeLevel} privilegeLevel={loadedData().privilegeLevel}
filters={filters()} filters={filters()}
sort={sort()} sort={sort()}
include={include()}
/> />
</div> </div>
</Show> </Show>

View File

@@ -1,8 +1,6 @@
import { Title, Meta } from "@solidjs/meta"; import { Title, Meta } from "@solidjs/meta";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore"; import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import GitHub from "~/components/icons/GitHub";
import LinkedIn from "~/components/icons/LinkedIn";
export default function DownloadsPage() { export default function DownloadsPage() {
const download = (assetName: string) => { const download = (assetName: string) => {
@@ -15,12 +13,6 @@ export default function DownloadsPage() {
.catch((error) => console.error(error)); .catch((error) => console.error(error));
}; };
const joinBetaPrompt = () => {
window.alert(
"This isn't released yet, if you would like to help test, please go the contact page and include the game and platform you would like to help test in the message. Otherwise the apk is available for direct install. Thanks!"
);
};
return ( return (
<> <>
<Title>Downloads | Michael Freno</Title> <Title>Downloads | Michael Freno</Title>
@@ -40,12 +32,12 @@ export default function DownloadsPage() {
<br /> <br />
</div> </div>
<div class="flex justify-evenly md:mx-[25vw]"> <div class="flex justify-evenly">
<div class="flex w-1/3 flex-col"> <div class="flex w-1/3 flex-col">
<div class="text-center text-lg">Android (apk only)</div> <div class="text-center text-lg">Android</div>
<button <button
onClick={() => download("lineage")} onClick={() => download("lineage")}
class="bg-blue mt-2 rounded-md px-4 py-2 text-base shadow-lg transition-all duration-200 ease-out hover:brightness-125 active:scale-95" class="bg-blue mx-auto mt-2 rounded-md px-4 py-2 text-base shadow-lg transition-all duration-200 ease-out hover:brightness-125 active:scale-95"
> >
Download APK Download APK
</button> </button>
@@ -53,20 +45,6 @@ export default function DownloadsPage() {
Note the android version is not well tested, and has performance Note the android version is not well tested, and has performance
issues. issues.
</div> </div>
<div class="rule-around">Or</div>
<div class="mx-auto italic">(Coming soon)</div>
<button
onClick={joinBetaPrompt}
class="mx-auto transition-all duration-200 ease-out active:scale-95"
>
<img
src="/google-play-badge.png"
alt="google-play"
width={180}
height={60}
/>
</button>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
@@ -88,28 +66,15 @@ export default function DownloadsPage() {
(apk and iOS) (apk and iOS)
</div> </div>
<div class="flex justify-evenly md:mx-[25vw]"> <div class="flex justify-evenly">
<div class="flex flex-col"> <div class="flex flex-col">
<div class="text-center text-lg">Android</div> <div class="text-center text-lg">Android</div>
<button <button
onClick={() => download("shapes-with-abigail")} onClick={() => download("shapes-with-abigail")}
class="bg-blue mt-2 rounded-md px-4 py-2 text-base shadow-lg transition-all duration-200 ease-out hover:brightness-125 active:scale-95" class="bg-blue mx-auto mt-2 rounded-md px-4 py-2 text-base shadow-lg transition-all duration-200 ease-out hover:brightness-125 active:scale-95"
> >
Download APK Download APK
</button> </button>
<div class="rule-around">Or</div>
<div class="mx-auto italic">(Coming soon)</div>
<button
onClick={joinBetaPrompt}
class="transition-all duration-200 ease-out active:scale-95"
>
<img
src="/google-play-badge.png"
alt="google-play"
width={180}
height={60}
/>
</button>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
@@ -142,33 +107,6 @@ export default function DownloadsPage() {
Just unzip and drag into 'Applications' folder Just unzip and drag into 'Applications' folder
</div> </div>
</div> </div>
<ul class="icons flex justify-center gap-4 pt-24 pb-6">
<li>
<A
href="https://github.com/MikeFreno/"
target="_blank"
rel="noreferrer"
class="shaker border-text inline-block rounded-full border transition-transform hover:scale-110"
>
<span class="m-auto block p-2">
<GitHub height={24} width={24} fill={undefined} />
</span>
</A>
</li>
<li>
<A
href="https://www.linkedin.com/in/michael-freno-176001256/"
target="_blank"
rel="noreferrer"
class="shaker border-text inline-block rounded-full border transition-transform hover:scale-110"
>
<span class="m-auto block rounded-md p-2">
<LinkedIn height={24} width={24} fill={undefined} />
</span>
</A>
</li>
</ul>
</div> </div>
</div> </div>
</> </>

View File

@@ -11,7 +11,7 @@ export default function Home() {
content="Michael Freno - Software Engineer based in Brooklyn, NY" content="Michael Freno - Software Engineer based in Brooklyn, NY"
/> />
<main class="flex h-full flex-col gap-8 px-4 text-xl"> <main class="flex h-full flex-col gap-8 px-4 py-16 text-xl">
<div class="flex-1"> <div class="flex-1">
<Typewriter speed={30} keepAlive={2000}> <Typewriter speed={30} keepAlive={2000}>
<div class="text-4xl">Hey!</div> <div class="text-4xl">Hey!</div>
@@ -153,6 +153,7 @@ export default function Home() {
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-between">
<Typewriter speed={120} class="mx-auto max-w-3/4 pt-8 md:max-w-1/2"> <Typewriter speed={120} class="mx-auto max-w-3/4 pt-8 md:max-w-1/2">
And if you love the color schemes of this site And if you love the color schemes of this site
<div class="mx-auto w-fit"> <div class="mx-auto w-fit">
@@ -165,21 +166,19 @@ export default function Home() {
> >
here here
</a>{" "} </a>{" "}
- and also see the rest of my various dot files idk. There's a macos - and also see the rest of my various dot files idk. There's a
and arch linux rice in there if you're into that kinda thing and a macos and arch linux rice in there if you're into that kinda thing
home server setup too. Which I will write about soon. and a home server setup too. Which I will write about soon.
</Typewriter> </Typewriter>
</div> <div class="flex flex-col items-end justify-center gap-4 pr-4">
<Typewriter speed={30} keepAlive={false}>
<div class="flex flex-col items-end gap-4 pr-4">
<Typewriter speed={50} keepAlive={false}>
<div> <div>
My Collection of My Collection of
<br /> <br />
By-the-ways: By-the-ways:
</div> </div>
</Typewriter> </Typewriter>
<Typewriter speed={50} keepAlive={false}> <Typewriter speed={30} keepAlive={false}>
<ul class="list-disc"> <ul class="list-disc">
<li>I use Neovim</li> <li>I use Neovim</li>
<li>I use Arch Linux</li> <li>I use Arch Linux</li>
@@ -187,6 +186,8 @@ export default function Home() {
</ul> </ul>
</Typewriter> </Typewriter>
</div> </div>
</div>
</div>
</main> </main>
</> </>
); );