starting refinement

This commit is contained in:
Michael Freno
2025-12-17 13:51:13 -05:00
parent 99ee7782e7
commit e02476b207
21 changed files with 1932 additions and 58 deletions

View File

@@ -2,20 +2,30 @@ import { Typewriter } from "./Typewriter";
export function LeftBar() {
return (
<nav class="w-fit max-w-[25%] min-h-screen h-full border-r-2 border-r-overlay2 flex flex-col text-text text-xl font-bold py-10 px-4 gap-4 text-left">
<nav class="border-r-overlay2 fixed h-full min-h-screen w-fit max-w-[25%] border-r-2">
<Typewriter speed={10} class="z-50 pr-8 pl-4">
<h3 class="hover:text-subtext0 w-fit text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105">
<a href="/">Freno.dev</a>
</h3>
</Typewriter>
<Typewriter keepAlive={false} class="z-50">
<h3 class="text-2xl">Left Navigation</h3>
<ul>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-subtext0 hover:font-bold hover:scale-110">
<a href="#home">Home</a>
</li>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-subtext0 hover:font-bold hover:scale-110">
<a href="#about">About</a>
</li>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-subtext0 hover:font-bold hover:scale-110">
<a href="#services">Services</a>
</li>
</ul>
<div class="text-text flex h-screen flex-col justify-between px-4 py-10 text-xl font-bold">
<ul class="gap-4">
{/*TODO:Grab and render 5 most recent blog posts here */}
<li></li>
</ul>
<ul class="gap-4">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/">Home</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/blog">Blog</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#services">Services</a>
</li>
</ul>
</div>
</Typewriter>
</nav>
);
@@ -23,17 +33,17 @@ export function LeftBar() {
export function RightBar() {
return (
<nav class="w-fit max-w-[25%] min-h-screen h-full border-l-2 border-l-overlay2 flex flex-col text-text text-xl font-bold py-10 px-4 gap-4 text-right">
<nav class="border-l-overlay2 text-text flex h-full min-h-screen w-fit max-w-[25%] flex-col gap-4 border-l-2 px-4 py-10 text-xl font-bold">
<Typewriter keepAlive={false} class="z-50">
<h3 class="text-2xl">Right Navigation</h3>
<h3 class="text-center text-2xl">Right Navigation</h3>
<ul>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-subtext0 hover:font-bold hover:scale-110">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#home">Home</a>
</li>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-subtext0 hover:font-bold hover:scale-110">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#about">About</a>
</li>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-subtext0 hover:font-bold hover:scale-110">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#services">Services</a>
</li>
</ul>

View File

@@ -34,7 +34,10 @@ export default function DeletionForm() {
createEffect(() => {
const timer = getClientCookie("deletionRequestSent");
if (timer) {
timerInterval = setInterval(() => calcRemainder(timer), 1000) as unknown as number;
timerInterval = setInterval(
() => calcRemainder(timer),
1000,
) as unknown as number;
onCleanup(() => {
if (timerInterval) {
clearInterval(timerInterval);
@@ -74,10 +77,14 @@ export default function DeletionForm() {
if (timerInterval) {
clearInterval(timerInterval);
}
timerInterval = setInterval(() => calcRemainder(timer), 1000) as unknown as number;
timerInterval = setInterval(
() => calcRemainder(timer),
1000,
) as unknown as number;
}
} else {
const errorMsg = result.error?.message || "Failed to send deletion request";
const errorMsg =
result.error?.message || "Failed to send deletion request";
setError(errorMsg);
}
} catch (err: any) {
@@ -127,9 +134,9 @@ export default function DeletionForm() {
disabled={loading()}
class={`${
loading()
? "bg-zinc-400"
: "bg-red-400 hover:bg-red-500 active:scale-90 dark:bg-red-600 dark:hover:bg-red-700"
} flex w-36 justify-center rounded py-3 font-light text-white shadow-lg shadow-red-300 transition-all duration-300 ease-out dark:shadow-red-700`}
? "bg-lavender"
: "bg-maroon hover:brightness-125 active:scale-90"
} flex w-36 justify-center rounded py-3 font-light text-white shadow-lg shadow-maroon transition-all duration-300 ease-out`}
>
<Show when={loading()} fallback="Send Deletion Request">
<LoadingSpinner height={24} width={24} />
@@ -153,9 +160,9 @@ export default function DeletionForm() {
<div
class={`${
emailSent()
? "text-green-400"
? "text-green"
: error() !== ""
? "text-red-400"
? "text-red"
: "select-none opacity-0"
} mt-4 flex justify-center text-center italic transition-opacity duration-300 ease-in-out`}
>

View File

@@ -4,7 +4,7 @@ export default function LoadingSpinner(props: {
}) {
return (
<picture class="animate-spin-reverse flex w-full justify-center">
<source srcSet="/WhiteLogo.png" media="(prefers-color-scheme: dark)" />
<source srcset="/WhiteLogo.png" media="(prefers-color-scheme: dark)" />
<img
src="/BlackLogo.png"
alt="logo"

View File

@@ -0,0 +1,86 @@
import { Show } from "solid-js";
import CardLinks from "./CardLinks";
import DeletePostButton from "./DeletePostButton";
export interface Post {
id: number;
title: string;
subtitle: string | null;
body: string | null;
banner_photo: string | null;
date: string;
published: boolean;
category: string;
author_id: string;
reads: number;
attachments: string | null;
total_likes: number;
total_comments: number;
}
export interface CardProps {
post: Post;
privilegeLevel: "anonymous" | "admin" | "user";
linkTarget: "blog" | "project";
}
export default function Card(props: CardProps) {
return (
<div class="relative z-0 mx-auto h-96 w-full overflow-hidden rounded-lg bg-white shadow-lg dark:bg-zinc-900 md:w-5/6 lg:w-3/4">
<Show when={props.privilegeLevel === "admin"}>
<div class="absolute top-0 w-full border-b border-white border-opacity-20 bg-white bg-opacity-40 px-2 py-4 backdrop-blur-md dark:border-black dark:bg-zinc-800 dark:bg-opacity-60 md:px-6">
<div class="flex justify-between">
<Show when={!props.post.published}>
<div class="whitespace-nowrap text-center text-lg text-black dark:text-white">
Not Published
</div>
</Show>
<DeletePostButton
type={props.linkTarget === "blog" ? "Blog" : "Project"}
postID={props.post.id}
/>
</div>
</div>
</Show>
<img
src={
props.post.banner_photo
? props.post.banner_photo
: props.linkTarget === "blog"
? "/bitcoin.jpg"
: "/blueprint.jpg"
}
alt={props.post.title.replaceAll("_", " ") + " banner"}
class="h-full w-full object-cover"
/>
<div class="absolute bottom-0 w-full border-t border-white border-opacity-20 bg-white bg-opacity-40 px-2 py-4 backdrop-blur-md dark:border-zinc-900 dark:bg-zinc-800 dark:bg-opacity-60 md:px-6">
<div class="flex flex-col items-center justify-between md:flex-row">
<div class="text-center md:text-left">
<div class="text-lg text-black dark:text-white md:text-xl">
{props.post.subtitle}
</div>
<div class="text-2xl text-black dark:text-white md:text-3xl">
{props.post.title.replaceAll("_", " ")}
</div>
</div>
<div class="flex w-full justify-around pt-2 md:w-1/3 md:justify-between md:pl-2 md:pt-0">
<div class="my-auto md:h-full">
<p class="whitespace-nowrap text-sm text-black dark:text-white">
{props.post.total_comments || 0} Comments
</p>
<p class="whitespace-nowrap text-sm text-black dark:text-white">
{props.post.total_likes || 0} Likes
</p>
</div>
<CardLinks
postTitle={props.post.title}
linkTarget={props.linkTarget}
privilegeLevel={props.privilegeLevel}
postID={props.post.id}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { createSignal, Show } from "solid-js";
import { A } from "@solidjs/router";
import LoadingSpinner from "~/components/LoadingSpinner";
export interface CardLinksProps {
postTitle: string;
postID: number;
linkTarget: string;
privilegeLevel: string;
}
export default function CardLinks(props: CardLinksProps) {
const [readLoading, setReadLoading] = createSignal(false);
const [editLoading, setEditLoading] = createSignal(false);
return (
<div class="flex flex-col">
<A
href={`/blog/${props.postTitle}`}
onClick={() => setReadLoading(true)}
class={`${
readLoading()
? "bg-zinc-400"
: props.linkTarget === "project"
? "bg-blue-400 hover:bg-blue-500 dark:bg-blue-600 dark:hover:bg-blue-700"
: "bg-orange-400 hover:bg-orange-500"
} mb-1 ml-2 flex rounded px-4 py-2 font-light text-white shadow transition-all duration-300 ease-out active:scale-90`}
>
<Show when={readLoading()} fallback="Read">
<LoadingSpinner height={24} width={24} />
</Show>
</A>
<Show when={props.privilegeLevel === "admin"}>
<A
href={`/blog/edit/${props.postID}`}
onClick={() => setEditLoading(true)}
class={`${
editLoading() ? "bg-zinc-400" : "bg-green-400 hover:bg-green-500"
} ml-2 flex rounded px-4 py-2 font-light text-white shadow transition-all duration-300 ease-out active:scale-90`}
>
<Show when={editLoading()} fallback="Edit">
<LoadingSpinner height={24} width={24} />
</Show>
</A>
</Show>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { createSignal, Show } from "solid-js";
import { api } from "~/lib/api";
import TrashIcon from "~/components/icons/TrashIcon";
import LoadingSpinner from "~/components/LoadingSpinner";
export interface DeletePostButtonProps {
type: string;
postID: number;
}
export default function DeletePostButton(props: DeletePostButtonProps) {
const [loading, setLoading] = createSignal(false);
const deletePostTrigger = async (e: Event) => {
e.preventDefault();
const affirm = window.confirm("Are you sure you want to delete?");
if (affirm) {
setLoading(true);
try {
await api.database.deletePost.mutate({ id: props.postID });
// Refresh the page after successful deletion
window.location.reload();
} catch (error) {
alert("Failed to delete post");
setLoading(false);
}
}
};
return (
<form onSubmit={deletePostTrigger} class="flex w-full justify-end">
<button type="submit">
<Show when={loading()} fallback={<TrashIcon height={24} width={24} strokeWidth={1.5} />}>
<LoadingSpinner height={24} width={24} />
</Show>
</button>
</form>
);
}

View File

@@ -0,0 +1,77 @@
import { For, Show } from "solid-js";
import Card, { Post } from "./Card";
export interface Tag {
id: number;
value: string;
post_id: number;
}
export interface PostSortingProps {
posts: Post[];
tags: Tag[];
privilegeLevel: "anonymous" | "admin" | "user";
type: "blog" | "project";
filters?: string;
sort?: string;
}
export default function PostSorting(props: PostSortingProps) {
const postsToFilter = () => {
const filterSet = new Set<number>();
props.tags.forEach((tag) => {
if (props.filters?.split("|").includes(tag.value.slice(1))) {
filterSet.add(tag.post_id);
}
});
return filterSet;
};
const filteredPosts = () => {
return props.posts.filter((post) => {
return !postsToFilter().has(post.id);
});
};
const sortedPosts = () => {
const posts = filteredPosts();
switch (props.sort) {
case "newest":
return [...posts].reverse();
case "oldest":
return [...posts];
case "most liked":
return [...posts].sort((a, b) => b.total_likes - a.total_likes);
case "most read":
return [...posts].sort((a, b) => b.reads - a.reads);
case "most comments":
return [...posts].sort((a, b) => b.total_comments - a.total_comments);
default:
return [...posts].reverse();
}
};
return (
<Show
when={!(props.posts.length > 0 && filteredPosts().length === 0)}
fallback={
<div class="pt-12 text-center text-2xl italic tracking-wide">
All posts filtered out!
</div>
}
>
<For each={sortedPosts()}>
{(post) => (
<div class="my-4">
<Card
post={post}
privilegeLevel={props.privilegeLevel}
linkTarget={props.type}
/>
</div>
)}
</For>
</Show>
);
}

View File

@@ -0,0 +1,107 @@
import { createSignal, createEffect, For, Show } from "solid-js";
import { useNavigate, useLocation, useSearchParams } from "@solidjs/router";
import Check from "~/components/icons/Check";
import UpDownArrows from "~/components/icons/UpDownArrows";
const sorting = [
{ val: "Newest" },
{ val: "Oldest" },
{ val: "Most Liked" },
{ val: "Most Read" },
{ val: "Most Comments" },
];
export interface PostSortingSelectProps {
type: "blog" | "project";
}
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;
createEffect(() => {
let newRoute = location.pathname + "?sort=" + selected().val.toLowerCase();
if (currentFilters()) {
newRoute += "&filter=" + currentFilters();
}
navigate(newRoute);
});
const handleSelect = (sort: { val: string }) => {
setSelected(sort);
setIsOpen(false);
};
return (
<div class="relative z-10 mt-1 w-72">
<button
type="button"
onClick={() => setIsOpen(!isOpen())}
class={`${
props.type === "project"
? "focus-visible:border-blue-600 focus-visible:ring-offset-blue-300"
: "focus-visible:border-orange-600 focus-visible:ring-offset-orange-300"
} relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 dark:bg-zinc-900 sm:text-sm`}
>
<span class="block truncate">{selected().val}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<UpDownArrows
strokeWidth={1.5}
height={24}
width={24}
class="fill-zinc-900 dark:fill-white"
/>
</span>
</button>
<Show when={isOpen()}>
<div class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-zinc-900 sm:text-sm">
<For each={sorting}>
{(sort) => (
<button
type="button"
onClick={() => handleSelect(sort)}
class={`relative w-full cursor-default select-none py-2 pl-10 pr-4 text-left ${
selected().val === sort.val
? props.type === "project"
? "bg-blue-100 text-blue-900"
: "bg-orange-100 text-orange-900"
: "text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-800"
}`}
>
<span
class={`block truncate ${
selected().val === sort.val ? "font-medium" : "font-normal"
}`}
>
{sort.val}
</span>
<Show when={selected().val === sort.val}>
<span
class={`${
props.type === "project"
? "text-blue-600"
: "text-orange-600"
} absolute inset-y-0 left-0 flex items-center pl-3`}
>
<Check
strokeWidth={1}
height={24}
width={24}
class="stroke-zinc-900 dark:stroke-white"
/>
</span>
</Show>
</button>
)}
</For>
</div>
</Show>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { createSignal, Show } from "solid-js";
import { api } from "~/lib/api";
import LikeIcon from "~/components/icons/LikeIcon";
export interface PostLike {
id: number;
user_id: string;
post_id: string;
}
export interface SessionDependantLikeProps {
currentUserID: string | undefined | null;
privilegeLevel: "admin" | "user" | "anonymous";
likes: PostLike[];
type: "blog" | "project";
projectID: number;
}
export default function SessionDependantLike(props: SessionDependantLikeProps) {
const [hovering, setHovering] = createSignal(false);
const [likes, setLikes] = createSignal(props.likes);
const [instantOffset, setInstantOffset] = createSignal(0);
const [hasLiked, setHasLiked] = createSignal(
props.likes.some((like) => like.user_id === props.currentUserID)
);
const giveProjectLike = async () => {
if (!props.currentUserID) return;
const initialHasLiked = hasLiked();
const initialInstantOffset = initialHasLiked ? -1 : 1;
setHasLiked(!hasLiked());
setInstantOffset(initialInstantOffset);
try {
if (initialHasLiked) {
const result = await api.database.removePostLike.mutate({
user_id: props.currentUserID,
post_id: props.projectID.toString(),
});
setLikes(result.newLikes as PostLike[]);
} else {
const result = await api.database.addPostLike.mutate({
user_id: props.currentUserID,
post_id: props.projectID.toString(),
});
setLikes(result.newLikes as PostLike[]);
}
setInstantOffset(0);
} catch (error) {
console.error("There has been a problem with your like operation:", error);
setHasLiked(initialHasLiked);
setInstantOffset(0);
}
};
const likeCount = () => likes().length + instantOffset();
const getLikeIconColor = () => {
if (hasLiked()) {
return props.type === "project" ? "fill-blue-400" : "fill-orange-400";
}
if (hovering()) {
return props.type === "project"
? "fill-blue-400 dark:fill-blue-600"
: "fill-orange-400 dark:fill-orange-500";
}
return "fill-black dark:fill-white";
};
return (
<Show
when={props.privilegeLevel !== "anonymous"}
fallback={
<button class="tooltip flex flex-col">
<div class="mx-auto">
<LikeIcon
strokeWidth={1}
color="fill-black dark:fill-white"
height={32}
width={32}
/>
</div>
<div class="my-auto pl-2 pt-0.5 text-sm text-black dark:text-white">
{likes().length} {likes().length === 1 ? "Like" : "Likes"}
</div>
<div class="tooltip-text -ml-12 w-12">Must be logged in</div>
</button>
}
>
<button
onClick={() => giveProjectLike()}
onMouseOver={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<div
class={`${
props.type === "project"
? "hover:text-blue-400"
: "hover:text-orange-400"
} tooltip flex flex-col text-black dark:text-white`}
>
<div class="mx-auto">
<LikeIcon
strokeWidth={1}
color={getLikeIconColor()}
height={32}
width={32}
/>
</div>
<div
class={`${
hasLiked()
? props.type === "project"
? "text-blue-400"
: "text-orange-400"
: ""
} mx-auto flex pl-2 transition-colors duration-200 ease-in`}
>
{likeCount()} {likeCount() === 1 ? "Like" : "Likes"}
</div>
<div class="tooltip-text -ml-14 w-12 px-2">
<Show
when={hasLiked()}
fallback={<div class="px-2 text-center">Leave a Like</div>}
>
<div class="px-2">Remove Like</div>
</Show>
</div>
</div>
</button>
</Show>
);
}

View File

@@ -0,0 +1,97 @@
import { createSignal, createEffect, For, Show, onCleanup } from "solid-js";
import { useNavigate, useLocation, useSearchParams } from "@solidjs/router";
export interface TagSelectorProps {
tagMap: Record<string, number>;
category: "blog" | "project";
}
export default function TagSelector(props: TagSelectorProps) {
const [showingMenu, setShowingMenu] = createSignal(false);
let buttonRef: HTMLButtonElement | undefined;
let menuRef: HTMLDivElement | undefined;
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const currentSort = () => searchParams.sort || "";
const currentFilters = () => searchParams.filter?.split("|") || [];
const handleClickOutside = (e: MouseEvent) => {
if (
buttonRef && menuRef &&
!buttonRef.contains(e.target as Node) &&
!menuRef.contains(e.target as Node)
) {
setShowingMenu(false);
}
};
createEffect(() => {
if (showingMenu()) {
document.addEventListener("click", handleClickOutside);
onCleanup(() => document.removeEventListener("click", handleClickOutside));
}
});
const toggleMenu = () => {
setShowingMenu(!showingMenu());
};
const handleCheck = (filter: string, isChecked: boolean) => {
if (isChecked) {
const newFilters = searchParams.filter?.replace(filter + "|", "");
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 + filter + "|";
navigate(`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`);
} else {
navigate(`${location.pathname}?sort=${currentSort()}&filter=${filter}|`);
}
}
};
return (
<>
<button
ref={buttonRef}
type="button"
onClick={toggleMenu}
class={`${
props.category === "project"
? "border-blue-500 bg-blue-400 hover:bg-blue-500 dark:border-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800"
: "border-orange-500 bg-orange-400 hover:bg-orange-500 dark:border-orange-600 dark:bg-orange-600 dark:hover:bg-orange-700"
} mt-2 rounded border px-4 py-2 font-light text-white shadow-md transition-all duration-300 ease-in-out active:scale-90 md:mt-0`}
>
Filters
</button>
<Show when={showingMenu()}>
<div
ref={menuRef}
class="absolute z-50 mt-12 rounded-lg bg-zinc-100 py-2 pl-2 pr-4 shadow-lg dark:bg-zinc-900"
>
<For each={Object.entries(props.tagMap)}>
{([key, value]) => (
<div class="mx-auto my-2 flex">
<input
type="checkbox"
checked={!currentFilters().includes(key.slice(1))}
onChange={(e) => handleCheck(key.slice(1), e.currentTarget.checked)}
/>
<div class="-mt-0.5 pl-1 text-sm font-normal">
{`${key.slice(1)} (${value}) `}
</div>
</div>
)}
</For>
</div>
</Show>
</>
);
}