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

@@ -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>