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

BIN
bun.lockb

Binary file not shown.

View File

@@ -34,6 +34,8 @@
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"trpc-panel": "^1.3.4"
}
}

8
prettier.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { type Config } from "prettier";
const config: Config = {
trailingComma: "none",
plugins: ["prettier-plugin-tailwindcss"]
};
export default config;

View File

@@ -130,10 +130,22 @@
:root {
font-family: "Source Code Pro", monospace;
}
::selection {
background-color: var(--color-text);
color: var(--color-crust);
}
::-moz-selection {
background-color: var(--color-text);
color: var(--color-crust);
}
::-webkit-selection {
background-color: var(--color-text);
color: var(--color-crust);
}
body {
background: var(--color-base);
color: var(--color-crust);
color: var(--color-text);
}
.cursor-typing {
@@ -166,3 +178,241 @@ body {
opacity: 0;
}
}
.input-group,
.textarea-group {
position: relative;
margin-top: 45px;
}
input.underlinedInput,
textarea.underlinedInput {
font-size: 18px;
padding: 10px 10px 10px 5px;
display: block;
border: none;
}
input.underlinedInput,
textarea.underlinedInput {
border-bottom: 1px solid var(--color-surface2);
}
textarea.underlinedInput {
resize: none;
}
input.underlinedInput:focus,
textarea.underlinedInput:focus {
outline: none;
}
.bar {
position: relative;
display: block;
}
.bar:before,
.bar:after {
content: "";
height: 2px;
width: 0;
bottom: 0px;
position: absolute;
transition: width 0.3s ease-out;
/*TODO:*/
background: var(--color-surface2);
}
.bar:before {
left: 50%;
}
.bar:after {
right: 50%;
}
input.underlinedInput:focus ~ .bar:before,
input.underlinedInput:focus ~ .bar:after,
textarea.underlinedInput:focus ~ .bar:before,
textarea.underlinedInput:focus ~ .bar:after {
width: 50%;
}
input.underlinedInput:focus ~ label,
input.underlinedInput:not(:placeholder-shown) ~ label,
textarea.underlinedInput:focus ~ label,
textarea.underlinedInput:not(:placeholder-shown) ~ label {
top: -20px;
font-size: 14px;
color: var(--color-blue);
}
.delete input.underlinedInput:focus ~ label,
.delete input.underlinedInput:not(:placeholder-shown) ~ label,
.delete textarea.underlinedInput:focus ~ label,
.delete textarea.underlinedInput:not(:placeholder-shown) ~ label {
color: var(--color-red);
}
label.underlinedInputLabel {
font-size: 18px;
font-weight: normal;
position: absolute;
pointer-events: none;
left: 5px;
top: 10px;
transition:
all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
label.underlinedInputLabel {
color: var(--color-surface1);
}
.logoSpinner:hover {
animation: spinner 1.5s ease;
}
@keyframes spinner {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
@keyframes spinReverse {
to {
transform: rotate(-360deg);
}
}
.animate-spin-reverse {
animation: spinReverse 1s linear infinite;
}
.vertical-rule-around {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: flex-end;
}
.vertical-rule-around:before,
.vertical-rule-around:after {
content: "";
flex: 1;
border-left: 1px solid var(--color-surface2);
margin: 10px 0;
}
.checkbox-container {
position: relative;
padding-left: 35px;
cursor: pointer;
font-size: 22px;
user-select: none;
}
.checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 25px;
width: 25px;
background-color: #eee;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.checkbox-container:hover input ~ .checkmark {
background-color: #ccc;
}
.checkbox-container input:checked ~ .checkmark {
background-color: #2196f3;
}
input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
background-color: var(--form-background);
margin: 0;
font: inherit;
width: 1.15em;
height: 1.15em;
border: 2px solid var(--color-blue);
border-radius: 0.15em;
transform: translateY(-0.075em);
display: grid;
place-content: center;
}
input[type="checkbox"]::before {
content: "";
width: 0.65em;
height: 0.65em;
clip-path: circle(25%);
transform: scale(0);
color: var(--color-blue);
transform-origin: center;
transition: 120ms transform ease-in-out;
/* Windows High Contrast Mode */
background-color: CanvasText;
}
@media (prefers-color-scheme: light) {
input[type="checkbox"]::before {
box-shadow: inset 1em 1em black;
}
}
@media (prefers-color-scheme: dark) {
input[type="checkbox"]::before {
box-shadow: inset 1em 1em white;
}
}
input[type="checkbox"]:checked::before {
transform: scale(1);
}
.tooltip {
position: relative;
}
.rule-around {
display: flex;
align-items: center;
}
.rule-around:before,
.rule-around:after {
content: "";
flex: 1;
border-top: 1px solid var(--color-text);
margin: 0 10px;
}
.hover-underline-animation {
display: inline-block;
position: relative;
}
.hover-underline-animation::after {
content: "";
position: absolute;
width: 100%;
transform: scaleX(0);
height: 1px;
bottom: 0;
left: 0;
transform-origin: bottom right;
transition: transform 0.35s ease-out;
}
.hover-underline-animation:hover::after {
transform: scaleX(1);
transform-origin: bottom left;
background-color: var(--color-surface2);
}

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>
<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:-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 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:-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>
</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>
</>
);
}

View File

@@ -0,0 +1,272 @@
import { Show, Suspense, For } from "solid-js";
import { useParams, A } from "@solidjs/router";
import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { cache } from "@solidjs/router";
import { ConnectionFactory } from "~/server/utils";
import { HttpStatusCode } from "@solidjs/start";
import SessionDependantLike from "~/components/blog/SessionDependantLike";
import CommentIcon from "~/components/icons/CommentIcon";
// Server function to fetch post by title
const getPostByTitle = cache(async (title: string, privilegeLevel: string) => {
"use server";
const conn = ConnectionFactory();
let query = "SELECT * FROM Post WHERE title = ?";
if (privilegeLevel !== "admin") {
query += ` AND published = TRUE`;
}
const postResults = await conn.execute({
sql: query,
args: [decodeURIComponent(title)],
});
const post = postResults.rows[0] as any;
if (!post) {
// Check if post exists but is unpublished
const existQuery = "SELECT id FROM Post WHERE title = ?";
const existRes = await conn.execute({
sql: existQuery,
args: [decodeURIComponent(title)],
});
if (existRes.rows[0]) {
return { post: null, exists: true, comments: [], likes: [], tags: [], userCommentMap: new Map() };
}
return { post: null, exists: false, comments: [], likes: [], tags: [], userCommentMap: new Map() };
}
// Fetch comments
const commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
const comments = (await conn.execute({ sql: commentQuery, args: [post.id] })).rows;
// Fetch likes
const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?";
const likes = (await conn.execute({ sql: likeQuery, args: [post.id] })).rows;
// Fetch tags
const tagQuery = "SELECT * FROM Tag WHERE post_id = ?";
const tags = (await conn.execute({ sql: tagQuery, args: [post.id] })).rows;
// Build commenter map
const commenterToCommentIDMap = new Map<string, number[]>();
comments.forEach((comment: any) => {
const prev = commenterToCommentIDMap.get(comment.commenter_id) || [];
commenterToCommentIDMap.set(comment.commenter_id, [...prev, comment.id]);
});
const commenterQuery = "SELECT email, display_name, image FROM User WHERE id = ?";
const commentIDToCommenterMap = new Map();
for (const [key, value] of commenterToCommentIDMap.entries()) {
const res = await conn.execute({ sql: commenterQuery, args: [key] });
const user = res.rows[0];
if (user) {
commentIDToCommenterMap.set(user, value);
}
}
// Get reaction map
const reactionMap = new Map();
for (const comment of comments) {
const reactionQuery = "SELECT * FROM CommentReaction WHERE comment_id = ?";
const res = await conn.execute({
sql: reactionQuery,
args: [(comment as any).id],
});
reactionMap.set((comment as any).id, res.rows);
}
return {
post,
exists: true,
comments,
likes,
tags,
topLevelComments: comments.filter((c: any) => c.parent_comment_id == null),
userCommentMap: commentIDToCommenterMap,
reactionMap,
};
}, "post-by-title");
export default function PostPage() {
const params = useParams();
// TODO: Get actual privilege level and user ID from session/auth
const privilegeLevel = "anonymous";
const userID = null;
const data = createAsync(() => getPostByTitle(params.title, privilegeLevel));
const hasCodeBlock = (str: string): boolean => {
return str.includes("<code") && str.includes("</code>");
};
return (
<>
<Suspense
fallback={
<div class="w-full pt-[30vh] text-center">
<div class="text-xl">Loading post...</div>
</div>
}
>
<Show
when={data()}
fallback={
<div class="w-full pt-[30vh]">
<HttpStatusCode code={404} />
<div class="text-center text-2xl">Post not found</div>
</div>
}
>
{(postData) => (
<Show
when={postData().post}
fallback={
<Show
when={postData().exists}
fallback={
<div class="w-full pt-[30vh]">
<HttpStatusCode code={404} />
<div class="text-center text-2xl">Post not found</div>
</div>
}
>
<div class="w-full pt-[30vh]">
<div class="text-center text-2xl">
That post is in the works! Come back soon!
</div>
<div class="flex justify-center">
<A
href="/blog"
class="mt-4 rounded border border-orange-500 bg-orange-400 px-4 py-2 text-white shadow-md transition-all duration-300 ease-in-out hover:bg-orange-500 active:scale-90 dark:border-orange-700 dark:bg-orange-700 dark:hover:bg-orange-800"
>
Back to Posts
</A>
</div>
</div>
</Show>
}
>
{(post) => {
const p = post().post;
return (
<>
<Title>{p.title.replaceAll("_", " ")} | Michael Freno</Title>
<div class="select-none overflow-x-hidden">
<div class="z-30">
<div class="page-fade-in z-20 mx-auto h-80 sm:h-96 md:h-[50vh]">
<div class="image-overlay fixed h-80 w-full brightness-75 sm:h-96 md:h-[50vh]">
<img
src={p.banner_photo || "/blueprint.jpg"}
alt="post-cover"
class="h-80 w-full object-cover sm:h-96 md:h-[50vh]"
/>
</div>
<div
class="text-shadow fixed top-36 z-10 w-full select-text text-center tracking-widest text-white brightness-150 sm:top-44 md:top-[20vh]"
style={{ "pointer-events": "none" }}
>
<div class="z-10 text-3xl font-light tracking-widest">
{p.title.replaceAll("_", " ")}
<div class="py-8 text-xl font-light tracking-widest">
{p.subtitle}
</div>
</div>
</div>
</div>
</div>
<div class="relative z-40 bg-zinc-100 pb-24 dark:bg-zinc-800">
<div class="top-4 flex w-full flex-col justify-center md:absolute md:flex-row md:justify-between">
<div class="">
<div class="flex justify-center italic md:justify-start md:pl-24">
<div>
Written {new Date(p.date).toDateString()}
<br />
By Michael Freno
</div>
</div>
<div class="flex max-w-[420px] flex-wrap justify-center italic md:justify-start md:pl-24">
<For each={postData().tags as any[]}>
{(tag) => (
<div class="group relative m-1 h-fit w-fit rounded-xl bg-purple-600 px-2 py-1 text-sm">
<div class="text-white">{tag.value}</div>
</div>
)}
</For>
</div>
</div>
<div class="flex flex-row justify-center pt-4 md:pr-8 md:pt-0">
<a href="#comments" class="mx-2">
<div class="tooltip flex flex-col">
<div class="mx-auto">
<CommentIcon strokeWidth={1} height={32} width={32} />
</div>
<div class="my-auto pl-2 pt-0.5 text-sm text-black dark:text-white">
{postData().comments.length}{" "}
{postData().comments.length === 1 ? "Comment" : "Comments"}
</div>
</div>
</a>
<div class="mx-2">
<SessionDependantLike
currentUserID={userID}
privilegeLevel={privilegeLevel}
likes={postData().likes as any[]}
type={p.category === "project" ? "project" : "blog"}
projectID={p.id}
/>
</div>
</div>
</div>
{/* Post body */}
<div class="mx-auto max-w-4xl px-4 pt-32 md:pt-40">
<div class="prose dark:prose-invert max-w-none" innerHTML={p.body} />
</div>
<Show when={privilegeLevel === "admin"}>
<div class="flex justify-center">
<A
class="z-100 h-fit rounded border border-blue-500 bg-blue-400 px-4 py-2 text-white shadow-md transition-all duration-300 ease-in-out hover:bg-blue-500 active:scale-90 dark:border-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800"
href={`/blog/edit/${p.id}`}
>
Edit
</A>
</div>
</Show>
{/* Comments section */}
<div id="comments" class="mx-4 pb-12 pt-12 md:mx-8 lg:mx-12">
<div class="mb-8 text-center text-2xl font-semibold">Comments</div>
<div class="mx-auto max-w-2xl rounded-lg border border-zinc-300 bg-zinc-50 p-6 text-center dark:border-zinc-700 dark:bg-zinc-900">
<p class="mb-2 text-lg text-zinc-700 dark:text-zinc-300">
Comments coming soon!
</p>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
We're working on implementing a comment system for this blog.
</p>
</div>
</div>
</div>
</div>
</>
);
}}
</Show>
)}
</Show>
</Suspense>
</>
);
}

View File

@@ -0,0 +1,205 @@
import { Show, createSignal } from "solid-js";
import { useSearchParams, useNavigate } from "@solidjs/router";
import { Title } from "@solidjs/meta";
import { api } from "~/lib/api";
export default function CreatePost() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
// TODO: Get actual privilege level from session/auth
const privilegeLevel = "anonymous";
const userID = null;
const category = () => searchParams.category === "project" ? "project" : "blog";
const [title, setTitle] = createSignal("");
const [subtitle, setSubtitle] = createSignal("");
const [body, setBody] = createSignal("");
const [bannerPhoto, setBannerPhoto] = createSignal("");
const [published, setPublished] = createSignal(false);
const [tags, setTags] = createSignal<string[]>([]);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!userID) {
setError("You must be logged in to create a post");
return;
}
setLoading(true);
setError("");
try {
const result = await api.database.createPost.mutate({
category: category(),
title: title(),
subtitle: subtitle() || null,
body: body() || null,
banner_photo: bannerPhoto() || null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: userID,
});
if (result.data) {
// Redirect to the new post
navigate(`/blog/${encodeURIComponent(title())}`);
}
} catch (err) {
console.error("Error creating post:", err);
setError("Failed to create post. Please try again.");
} finally {
setLoading(false);
}
};
return (
<>
<Title>Create {category() === "project" ? "Project" : "Blog Post"} | Michael Freno</Title>
<Show
when={privilegeLevel === "admin"}
fallback={
<div class="w-full pt-[30vh] text-center">
<div class="text-2xl">Unauthorized</div>
<div class="text-gray-600 dark:text-gray-400 mt-4">
You must be an admin to create posts.
</div>
</div>
}
>
<div class="min-h-screen bg-white dark:bg-zinc-900 py-12 px-4">
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold text-center mb-8">
Create {category() === "project" ? "Project" : "Blog Post"}
</h1>
<form onSubmit={handleSubmit} class="space-y-6">
{/* Title */}
<div>
<label for="title" class="block text-sm font-medium mb-2">
Title *
</label>
<input
id="title"
type="text"
required
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
placeholder="Enter post title"
/>
</div>
{/* Subtitle */}
<div>
<label for="subtitle" class="block text-sm font-medium mb-2">
Subtitle
</label>
<input
id="subtitle"
type="text"
value={subtitle()}
onInput={(e) => setSubtitle(e.currentTarget.value)}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
placeholder="Enter post subtitle"
/>
</div>
{/* Body */}
<div>
<label for="body" class="block text-sm font-medium mb-2">
Body (HTML)
</label>
<textarea
id="body"
rows={15}
value={body()}
onInput={(e) => setBody(e.currentTarget.value)}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700 font-mono text-sm"
placeholder="Enter post content (HTML)"
/>
</div>
{/* Banner Photo URL */}
<div>
<label for="banner" class="block text-sm font-medium mb-2">
Banner Photo URL
</label>
<input
id="banner"
type="text"
value={bannerPhoto()}
onInput={(e) => setBannerPhoto(e.currentTarget.value)}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
placeholder="Enter banner photo URL"
/>
</div>
{/* Tags */}
<div>
<label for="tags" class="block text-sm font-medium mb-2">
Tags (comma-separated)
</label>
<input
id="tags"
type="text"
value={tags().join(", ")}
onInput={(e) => setTags(e.currentTarget.value.split(",").map(t => t.trim()).filter(Boolean))}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
placeholder="tag1, tag2, tag3"
/>
</div>
{/* Published */}
<div class="flex items-center gap-2">
<input
id="published"
type="checkbox"
checked={published()}
onChange={(e) => setPublished(e.currentTarget.checked)}
class="h-4 w-4"
/>
<label for="published" class="text-sm font-medium">
Publish immediately
</label>
</div>
{/* Error message */}
<Show when={error()}>
<div class="text-red-500 text-sm">{error()}</div>
</Show>
{/* Submit button */}
<div class="flex gap-4">
<button
type="submit"
disabled={loading()}
class={`flex-1 px-6 py-3 rounded-md text-white transition-all ${
loading()
? "bg-gray-400 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 active:scale-95"
}`}
>
{loading() ? "Creating..." : "Create Post"}
</button>
<button
type="button"
onClick={() => navigate("/blog")}
class="px-6 py-3 rounded-md border border-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-all"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</Show>
</>
);
}

View File

@@ -0,0 +1,254 @@
import { Show, createSignal, createEffect } from "solid-js";
import { useParams, useNavigate } from "@solidjs/router";
import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { cache } from "@solidjs/router";
import { api } from "~/lib/api";
import { ConnectionFactory } from "~/server/utils";
// Server function to fetch post for editing
const getPostForEdit = cache(async (id: string) => {
"use server";
const conn = ConnectionFactory();
const query = `SELECT * FROM Post WHERE id = ?`;
const results = await conn.execute({
sql: query,
args: [id],
});
const tagQuery = `SELECT * FROM Tag WHERE post_id = ?`;
const tagRes = await conn.execute({
sql: tagQuery,
args: [id],
});
const post = results.rows[0];
const tags = tagRes.rows;
return { post, tags };
}, "post-for-edit");
export default function EditPost() {
const params = useParams();
const navigate = useNavigate();
// TODO: Get actual privilege level from session/auth
const privilegeLevel = "anonymous";
const userID = null;
const data = createAsync(() => getPostForEdit(params.id));
const [title, setTitle] = createSignal("");
const [subtitle, setSubtitle] = createSignal("");
const [body, setBody] = createSignal("");
const [bannerPhoto, setBannerPhoto] = createSignal("");
const [published, setPublished] = createSignal(false);
const [tags, setTags] = createSignal<string[]>([]);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
// Populate form when data loads
createEffect(() => {
const postData = data();
if (postData?.post) {
const p = postData.post as any;
setTitle(p.title || "");
setSubtitle(p.subtitle || "");
setBody(p.body || "");
setBannerPhoto(p.banner_photo || "");
setPublished(p.published || false);
if (postData.tags) {
const tagValues = (postData.tags as any[]).map(t => t.value);
setTags(tagValues);
}
}
});
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!userID) {
setError("You must be logged in to edit posts");
return;
}
setLoading(true);
setError("");
try {
await api.database.updatePost.mutate({
id: parseInt(params.id),
title: title(),
subtitle: subtitle() || null,
body: body() || null,
banner_photo: bannerPhoto() || null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: userID,
});
// Redirect to the post
navigate(`/blog/${encodeURIComponent(title())}`);
} catch (err) {
console.error("Error updating post:", err);
setError("Failed to update post. Please try again.");
} finally {
setLoading(false);
}
};
return (
<>
<Title>Edit Post | Michael Freno</Title>
<Show
when={privilegeLevel === "admin"}
fallback={
<div class="w-full pt-[30vh] text-center">
<div class="text-2xl">Unauthorized</div>
<div class="text-gray-600 dark:text-gray-400 mt-4">
You must be an admin to edit posts.
</div>
</div>
}
>
<Show
when={data()}
fallback={
<div class="w-full pt-[30vh] text-center">
<div class="text-xl">Loading post...</div>
</div>
}
>
<div class="min-h-screen bg-white dark:bg-zinc-900 py-12 px-4">
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold text-center mb-8">Edit Post</h1>
<form onSubmit={handleSubmit} class="space-y-6">
{/* Title */}
<div>
<label for="title" class="block text-sm font-medium mb-2">
Title *
</label>
<input
id="title"
type="text"
required
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
placeholder="Enter post title"
/>
</div>
{/* Subtitle */}
<div>
<label for="subtitle" class="block text-sm font-medium mb-2">
Subtitle
</label>
<input
id="subtitle"
type="text"
value={subtitle()}
onInput={(e) => setSubtitle(e.currentTarget.value)}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
placeholder="Enter post subtitle"
/>
</div>
{/* Body */}
<div>
<label for="body" class="block text-sm font-medium mb-2">
Body (HTML)
</label>
<textarea
id="body"
rows={15}
value={body()}
onInput={(e) => setBody(e.currentTarget.value)}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700 font-mono text-sm"
placeholder="Enter post content (HTML)"
/>
</div>
{/* Banner Photo URL */}
<div>
<label for="banner" class="block text-sm font-medium mb-2">
Banner Photo URL
</label>
<input
id="banner"
type="text"
value={bannerPhoto()}
onInput={(e) => setBannerPhoto(e.currentTarget.value)}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
placeholder="Enter banner photo URL"
/>
</div>
{/* Tags */}
<div>
<label for="tags" class="block text-sm font-medium mb-2">
Tags (comma-separated)
</label>
<input
id="tags"
type="text"
value={tags().join(", ")}
onInput={(e) => setTags(e.currentTarget.value.split(",").map(t => t.trim()).filter(Boolean))}
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
placeholder="tag1, tag2, tag3"
/>
</div>
{/* Published */}
<div class="flex items-center gap-2">
<input
id="published"
type="checkbox"
checked={published()}
onChange={(e) => setPublished(e.currentTarget.checked)}
class="h-4 w-4"
/>
<label for="published" class="text-sm font-medium">
Published
</label>
</div>
{/* Error message */}
<Show when={error()}>
<div class="text-red-500 text-sm">{error()}</div>
</Show>
{/* Submit button */}
<div class="flex gap-4">
<button
type="submit"
disabled={loading()}
class={`flex-1 px-6 py-3 rounded-md text-white transition-all ${
loading()
? "bg-gray-400 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 active:scale-95"
}`}
>
{loading() ? "Saving..." : "Save Changes"}
</button>
<button
type="button"
onClick={() => navigate(`/blog/${encodeURIComponent(title())}`)}
class="px-6 py-3 rounded-md border border-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-all"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</Show>
</Show>
</>
);
}

199
src/routes/blog/index.tsx Normal file
View File

@@ -0,0 +1,199 @@
import { createSignal, Show, Suspense } from "solid-js";
import { useSearchParams, A } from "@solidjs/router";
import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { cache } from "@solidjs/router";
import { ConnectionFactory } from "~/server/utils";
import PostSortingSelect from "~/components/blog/PostSortingSelect";
import TagSelector from "~/components/blog/TagSelector";
import PostSorting from "~/components/blog/PostSorting";
// Server function to fetch posts
const getPosts = cache(async (category: string, privilegeLevel: string) => {
"use server";
let query = `
SELECT
Post.id,
Post.title,
Post.subtitle,
Post.body,
Post.banner_photo,
Post.date,
Post.published,
Post.category,
Post.author_id,
Post.reads,
Post.attachments,
(SELECT COUNT(*) FROM PostLike WHERE Post.id = PostLike.post_id) AS total_likes,
(SELECT COUNT(*) FROM Comment WHERE Post.id = Comment.post_id) AS total_comments
FROM
Post
LEFT JOIN
PostLike ON Post.id = PostLike.post_id
LEFT JOIN
Comment ON Post.id = Comment.post_id`;
if (privilegeLevel !== "admin") {
query += ` WHERE Post.published = TRUE`;
if (category !== "all") {
query += ` AND Post.category = '${category}'`;
}
} else {
if (category !== "all") {
query += ` WHERE Post.category = '${category}'`;
}
}
query += ` GROUP BY Post.id, Post.title, Post.subtitle, Post.body, Post.banner_photo, Post.date, Post.published, Post.category, Post.author_id, Post.reads, Post.attachments ORDER BY Post.date DESC;`;
const conn = ConnectionFactory();
const results = await conn.execute(query);
const posts = results.rows;
const postIds = posts.map((post: any) => post.id);
const tagQuery = postIds.length > 0
? `SELECT * FROM Tag WHERE post_id IN (${postIds.join(", ")})`
: "SELECT * FROM Tag WHERE 1=0";
const tagResults = await conn.execute(tagQuery);
const tags = tagResults.rows;
let tagMap: Record<string, number> = {};
tags.forEach((tag: any) => {
tagMap[tag.value] = (tagMap[tag.value] || 0) + 1;
});
return { posts, tags, tagMap };
}, "blog-posts");
export default function BlogIndex() {
const [searchParams] = useSearchParams();
// TODO: Get actual privilege level from session/auth
const privilegeLevel = "anonymous";
const category = () => searchParams.category || "all";
const sort = () => searchParams.sort || "newest";
const filters = () => searchParams.filter || "";
const data = createAsync(() => getPosts(category(), privilegeLevel));
const bannerImage = () => category() === "project" ? "/blueprint.jpg" : "/manhattan-night-skyline.jpg";
const pageTitle = () => category() === "all" ? "Posts" : category() === "project" ? "Projects" : "Blog";
return (
<>
<Title>{pageTitle()} | Michael Freno</Title>
<div class="min-h-screen overflow-x-hidden bg-white dark:bg-zinc-900">
<div class="z-30">
<div class="page-fade-in z-20 mx-auto h-80 sm:h-96 md:h-[30vh]">
<div class="image-overlay fixed h-80 w-full brightness-75 sm:h-96 md:h-[50vh]">
<img
src={bannerImage()}
alt="post-cover"
class="h-80 w-full object-cover sm:h-96 md:h-[50vh]"
/>
</div>
<div
class="text-shadow fixed top-36 z-10 w-full select-text text-center tracking-widest text-white brightness-150 sm:top-44 md:top-[20vh]"
style={{ "pointer-events": "none" }}
>
<div class="z-10 text-5xl font-light tracking-widest">
{pageTitle()}
</div>
</div>
</div>
</div>
<div class="relative z-40 mx-auto -mt-16 min-h-screen w-11/12 rounded-t-lg bg-zinc-50 pb-24 pt-8 shadow-2xl dark:bg-zinc-800 sm:-mt-20 md:mt-0 md:w-5/6 lg:w-3/4">
<Suspense
fallback={
<div class="mx-auto pt-48">
<div class="text-center">Loading...</div>
</div>
}
>
<div class="flex flex-col justify-center gap-4 md:flex-row md:justify-around">
<div class="flex justify-center gap-2 md:justify-start">
<A
href="/blog?category=all"
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
category() === "all"
? "border-orange-500 bg-orange-400 text-white dark:border-orange-700 dark:bg-orange-700"
: "border-zinc-800 hover:bg-zinc-200 dark:border-white dark:hover:bg-zinc-700"
}`}
>
All
</A>
<A
href="/blog?category=blog"
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
category() === "blog"
? "border-orange-500 bg-orange-400 text-white dark:border-orange-700 dark:bg-orange-700"
: "border-zinc-800 hover:bg-zinc-200 dark:border-white dark:hover:bg-zinc-700"
}`}
>
Blog
</A>
<A
href="/blog?category=project"
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
category() === "project"
? "border-blue-500 bg-blue-400 text-white dark:border-blue-700 dark:bg-blue-700"
: "border-zinc-800 hover:bg-zinc-200 dark:border-white dark:hover:bg-zinc-700"
}`}
>
Projects
</A>
</div>
<PostSortingSelect type={category() === "project" ? "project" : "blog"} />
<Show when={data() && Object.keys(data()!.tagMap).length > 0}>
<TagSelector
tagMap={data()!.tagMap}
category={category() === "project" ? "project" : "blog"}
/>
</Show>
<Show when={privilegeLevel === "admin"}>
<div class="mt-2 flex justify-center md:mt-0 md:justify-end">
<A
href="/blog/create"
class="rounded border border-zinc-800 px-4 py-2 transition-all duration-300 ease-out hover:bg-zinc-200 active:scale-90 dark:border-white dark:hover:bg-zinc-700 md:mr-4"
>
Create Post
</A>
</div>
</Show>
</div>
</Suspense>
<Suspense
fallback={
<div class="mx-auto pt-48">
<div class="text-center">Loading posts...</div>
</div>
}
>
<Show
when={data() && data()!.posts.length > 0}
fallback={<div class="text-center pt-12">No posts yet!</div>}
>
<div class="mx-auto flex w-11/12 flex-col pt-8">
<PostSorting
posts={data()!.posts}
tags={data()!.tags}
privilegeLevel={privilegeLevel}
type={category() === "project" ? "project" : "blog"}
filters={filters()}
sort={sort()}
/>
</div>
</Show>
</Suspense>
</div>
</div>
</>
);
}

View File

@@ -3,18 +3,17 @@ import DeletionForm from "~/components/DeletionForm";
export default function LifeAndLinageDeletionForm() {
return (
<div class="pt-20">
<div class="container mx-auto p-4 md:p-6 lg:p-12">
<div class="w-full justify-center">
<div class="mx-auto p-4 md:p-6 lg:p-12">
<div class="w-full justify-center text-text">
<div class="text-xl">
<em>What will happen</em>:
</div>
Once you send, if a match to the email provided is found in our
system, a 24hr grace period is started where you can request a
cancellation of the account deletion. Once the grace period ends, the
account's entry in our central database will be completely
removed, and your individual database storing your remote saves will
also be deleted. No data related to the account is retained in any
way.
account's entry in our central database will be completely removed,
and your individual database storing your remote saves will also be
deleted. No data related to the account is retained in any way.
</div>
<DeletionForm />

View File

@@ -24,8 +24,10 @@ export default function LoginPage() {
const [showPasswordInput, setShowPasswordInput] = createSignal(false);
const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false);
const [passwordsMatch, setPasswordsMatch] = createSignal(false);
const [showPasswordLengthWarning, setShowPasswordLengthWarning] = createSignal(false);
const [passwordLengthSufficient, setPasswordLengthSufficient] = createSignal(false);
const [showPasswordLengthWarning, setShowPasswordLengthWarning] =
createSignal(false);
const [passwordLengthSufficient, setPasswordLengthSufficient] =
createSignal(false);
const [passwordBlurred, setPasswordBlurred] = createSignal(false);
// Form refs
@@ -60,7 +62,10 @@ export default function LoginPage() {
createEffect(() => {
const timer = getClientCookie("emailLoginLinkRequested");
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);
@@ -130,7 +135,11 @@ export default function LoginPage() {
const response = await fetch("/api/trpc/auth.emailRegistration", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, passwordConfirmation: passwordConf }),
body: JSON.stringify({
email,
password,
passwordConfirmation: passwordConf,
}),
});
const result = await response.json();
@@ -138,8 +147,14 @@ export default function LoginPage() {
if (response.ok && result.result?.data) {
navigate("/account");
} else {
const errorMsg = result.error?.message || result.result?.data?.message || "Registration failed";
if (errorMsg.includes("duplicate") || errorMsg.includes("already exists")) {
const errorMsg =
result.error?.message ||
result.result?.data?.message ||
"Registration failed";
if (
errorMsg.includes("duplicate") ||
errorMsg.includes("already exists")
) {
setError("duplicate");
} else {
setError(errorMsg);
@@ -206,10 +221,16 @@ export default function LoginPage() {
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 || result.result?.data?.message || "Failed to send email";
const errorMsg =
result.error?.message ||
result.result?.data?.message ||
"Failed to send email";
setError(errorMsg);
}
}
@@ -248,7 +269,11 @@ export default function LoginPage() {
};
const passwordLengthBlurCheck = () => {
if (!passwordLengthSufficient() && passwordRef && passwordRef.value !== "") {
if (
!passwordLengthSufficient() &&
passwordRef &&
passwordRef.value !== ""
) {
setShowPasswordLengthWarning(true);
}
setPasswordBlurred(true);
@@ -271,27 +296,31 @@ export default function LoginPage() {
};
return (
<div class="flex h-[100dvh] flex-row justify-evenly">
<div class="flex h-dvh flex-row justify-evenly">
{/* Logo section - hidden on mobile */}
<div class="hidden md:flex">
{/* <div class="hidden md:flex">
<div class="vertical-rule-around z-0 flex justify-center">
<picture class="-mr-8">
<source srcSet="/WhiteLogo.png" media="(prefers-color-scheme: dark)" />
<source srcset="/WhiteLogo.png" media="(prefers-color-scheme: dark)" />
<img src="/BlackLogo.png" alt="logo" width={64} height={64} />
</picture>
</div>
</div>
</div> */}
{/* Main content */}
<div class="pt-24 md:pt-48">
{/* Error message */}
<div class="absolute -mt-12 text-center text-3xl italic text-red-400">
<Show when={error() === "passwordMismatch"}>Passwords did not match!</Show>
<Show when={error() === "passwordMismatch"}>
Passwords did not match!
</Show>
<Show when={error() === "duplicate"}>Email Already Exists!</Show>
</div>
{/* Title */}
<div class="py-2 pl-6 text-2xl md:pl-0">{register() ? "Register" : "Login"}</div>
<div class="py-2 pl-6 text-2xl md:pl-0">
{register() ? "Register" : "Login"}
</div>
{/* Toggle Register/Login */}
<Show
@@ -304,7 +333,7 @@ export default function LoginPage() {
setRegister(false);
setUsePassword(false);
}}
class="pl-1 text-blue-400 underline dark:text-blue-600 hover:text-blue-300 dark:hover:text-blue-500"
class="pl-1 text-blue underline hover:brightness-125"
>
Click here to Login
</button>
@@ -318,7 +347,7 @@ export default function LoginPage() {
setRegister(true);
setUsePassword(false);
}}
class="pl-1 text-blue-400 underline dark:text-blue-600 hover:text-blue-300 dark:hover:text-blue-500"
class="pl-1 text-blue underline hover:brightness-125"
>
Click here to Register
</button>
@@ -470,8 +499,12 @@ export default function LoginPage() {
: "select-none opacity-0"
} flex min-h-[16px] justify-center italic transition-opacity duration-300 ease-in-out`}
>
<Show when={showPasswordError()}>Credentials did not match any record</Show>
<Show when={showPasswordSuccess()}>Login Success! Redirecting...</Show>
<Show when={showPasswordError()}>
Credentials did not match any record
</Show>
<Show when={showPasswordSuccess()}>
Login Success! Redirecting...
</Show>
</div>
{/* Submit button or countdown timer */}
@@ -485,10 +518,14 @@ export default function LoginPage() {
class={`${
loading()
? "bg-zinc-400"
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
} flex w-36 justify-center rounded py-3 text-white shadow-lg shadow-blue-300 transition-all duration-300 ease-out dark:shadow-blue-700`}
: "bg-blue hover:brightness-125 active:scale-90"
} flex w-36 justify-center rounded py-3 text-white transition-all duration-300 ease-out`}
>
{register() ? "Sign Up" : usePassword() ? "Sign In" : "Get Link"}
{register()
? "Sign Up"
: usePassword()
? "Sign In"
: "Get Link"}
</button>
}
>
@@ -530,7 +567,7 @@ export default function LoginPage() {
<div class="pb-4 text-center text-sm">
Trouble Logging In?{" "}
<A
class="text-blue-500 underline underline-offset-4 hover:text-blue-400"
class="text-blue underline underline-offset-4 hover:brightness-125"
href="/login/request-password-reset"
>
Reset Password
@@ -542,7 +579,7 @@ export default function LoginPage() {
<div
class={`${
emailSent() ? "" : "user-select opacity-0"
} flex min-h-[16px] justify-center text-center italic text-green-400 transition-opacity duration-300 ease-in-out`}
} flex min-h-4 justify-center text-center italic text-green transition-opacity duration-300 ease-in-out`}
>
<Show when={emailSent()}>Email Sent!</Show>
</div>

View File

@@ -364,6 +364,46 @@ export const databaseRouter = createTRPCRouter({
}
}),
deletePost: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
try {
const conn = ConnectionFactory();
// Delete associated tags first
await conn.execute({
sql: "DELETE FROM Tag WHERE post_id = ?",
args: [input.id.toString()],
});
// Delete associated likes
await conn.execute({
sql: "DELETE FROM PostLike WHERE post_id = ?",
args: [input.id.toString()],
});
// Delete associated comments
await conn.execute({
sql: "DELETE FROM Comment WHERE post_id = ?",
args: [input.id],
});
// Finally delete the post
await conn.execute({
sql: "DELETE FROM Post WHERE id = ?",
args: [input.id],
});
return { success: true };
} catch (error) {
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete post",
});
}
}),
// ============================================================
// Post Likes Routes
// ============================================================