removed excess comments

This commit is contained in:
Michael Freno
2026-01-04 11:14:54 -05:00
parent b81de6441b
commit 7e89e6dda2
68 changed files with 72 additions and 941 deletions

View File

@@ -27,21 +27,18 @@ function AppLayout(props: { children: any }) {
let lastScrollY = 0; let lastScrollY = 0;
// Use onMount to avoid hydration issues - window operations are client-only
onMount(() => { onMount(() => {
const windowWidth = createWindowWidth(); const windowWidth = createWindowWidth();
createEffect(() => { createEffect(() => {
const currentIsMobile = isMobile(windowWidth()); const currentIsMobile = isMobile(windowWidth());
// Show bars when switching to desktop
if (!currentIsMobile) { if (!currentIsMobile) {
setLeftBarVisible(true); setLeftBarVisible(true);
setRightBarVisible(true); setRightBarVisible(true);
} }
}); });
// Hide leftbar on mobile after 500ms with translation animation
const currentIsMobile = isMobile(windowWidth()); const currentIsMobile = isMobile(windowWidth());
if (currentIsMobile) { if (currentIsMobile) {
setTimeout(() => { setTimeout(() => {
@@ -50,7 +47,6 @@ function AppLayout(props: { children: any }) {
} }
}); });
// Auto-hide on scroll (mobile only)
onMount(() => { onMount(() => {
const windowWidth = createWindowWidth(); const windowWidth = createWindowWidth();
@@ -59,7 +55,6 @@ function AppLayout(props: { children: any }) {
const currentIsMobile = isMobile(windowWidth()); const currentIsMobile = isMobile(windowWidth());
if (currentIsMobile && currentScrollY > MOBILE_CONFIG.SCROLL_THRESHOLD) { if (currentIsMobile && currentScrollY > MOBILE_CONFIG.SCROLL_THRESHOLD) {
// Scrolling down past threshold - hide left bar on mobile
if (currentScrollY > lastScrollY) { if (currentScrollY > lastScrollY) {
setLeftBarVisible(false); setLeftBarVisible(false);
} }
@@ -75,7 +70,6 @@ function AppLayout(props: { children: any }) {
}); });
}); });
// ESC key to close sidebars on mobile
onMount(() => { onMount(() => {
const windowWidth = createWindowWidth(); const windowWidth = createWindowWidth();
@@ -99,7 +93,6 @@ function AppLayout(props: { children: any }) {
}); });
}); });
// Global swipe gestures to reveal/hide bars
onMount(() => { onMount(() => {
const windowWidth = createWindowWidth(); const windowWidth = createWindowWidth();
let touchStartX = 0; let touchStartX = 0;
@@ -117,7 +110,6 @@ function AppLayout(props: { children: any }) {
const deltaY = touchEndY - touchStartY; const deltaY = touchEndY - touchStartY;
const currentIsMobile = isMobile(windowWidth()); const currentIsMobile = isMobile(windowWidth());
// Only trigger if horizontal swipe is dominant
if (currentIsMobile && Math.abs(deltaX) > Math.abs(deltaY)) { if (currentIsMobile && Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > MOBILE_CONFIG.SWIPE_THRESHOLD) { if (deltaX > MOBILE_CONFIG.SWIPE_THRESHOLD) {
setLeftBarVisible(true); setLeftBarVisible(true);
@@ -140,7 +132,6 @@ function AppLayout(props: { children: any }) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const currentIsMobile = isMobile(window.innerWidth); const currentIsMobile = isMobile(window.innerWidth);
// Only hide left bar on mobile when it's visible
if (currentIsMobile && leftBarVisible()) { if (currentIsMobile && leftBarVisible()) {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const isInteractive = target.closest( const isInteractive = target.closest(

View File

@@ -10,22 +10,18 @@ export const ActivityHeatmap: Component<{
contributions: ContributionDay[] | undefined; contributions: ContributionDay[] | undefined;
title: string; title: string;
}> = (props) => { }> = (props) => {
// Generate last 12 weeks of days
const weeks = createMemo(() => { const weeks = createMemo(() => {
const today = new Date(); const today = new Date();
const weeksData: { date: string; count: number }[][] = []; const weeksData: { date: string; count: number }[][] = [];
// Start from 12 weeks ago
const startDate = new Date(today); const startDate = new Date(today);
startDate.setDate(startDate.getDate() - 84); // 12 weeks startDate.setDate(startDate.getDate() - 84);
// Create a map for quick lookup
const contributionMap = new Map<string, number>(); const contributionMap = new Map<string, number>();
props.contributions?.forEach((c) => { props.contributions?.forEach((c) => {
contributionMap.set(c.date, c.count); contributionMap.set(c.date, c.count);
}); });
// Generate weeks
for (let week = 0; week < 12; week++) { for (let week = 0; week < 12; week++) {
const weekData: { date: string; count: number }[] = []; const weekData: { date: string; count: number }[] = [];
@@ -68,7 +64,6 @@ export const ActivityHeatmap: Component<{
when={props.contributions && props.contributions.length > 0} when={props.contributions && props.contributions.length > 0}
fallback={ fallback={
<div class="relative"> <div class="relative">
{/* Skeleton grid matching heatmap dimensions */}
<div class="flex gap-[2px]"> <div class="flex gap-[2px]">
<For each={Array(12)}> <For each={Array(12)}>
{() => ( {() => (
@@ -80,7 +75,6 @@ export const ActivityHeatmap: Component<{
)} )}
</For> </For>
</div> </div>
{/* Centered spinner overlay */}
<div class="absolute inset-0 top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2"> <div class="absolute inset-0 top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2">
<SkeletonBox class="-ml-2 h-8 w-8" /> <SkeletonBox class="-ml-2 h-8 w-8" />
</div> </div>

View File

@@ -26,20 +26,14 @@ function formatDomainName(url: string): string {
return withoutWww.charAt(0).toUpperCase() + withoutWww.slice(1); return withoutWww.charAt(0).toUpperCase() + withoutWww.slice(1);
} }
/**
* Converts a banner photo URL to its thumbnail version
* Replaces the filename with -small variant (e.g., image.jpg -> image-small.jpg)
*/
function getThumbnailUrl(bannerPhoto: string | null): string { function getThumbnailUrl(bannerPhoto: string | null): string {
if (!bannerPhoto) return "/blueprint.jpg"; if (!bannerPhoto) return "/blueprint.jpg";
// Check if URL contains a file extension
const match = bannerPhoto.match(/^(.+)(\.[^.]+)$/); const match = bannerPhoto.match(/^(.+)(\.[^.]+)$/);
if (match) { if (match) {
return `${match[1]}-small${match[2]}`; return `${match[1]}-small${match[2]}`;
} }
// Fallback to original if no extension found
return bannerPhoto; return bannerPhoto;
} }
@@ -77,7 +71,6 @@ export function RightBarContent() {
}; };
onMount(() => { onMount(() => {
// Fetch all data client-side only to avoid hydration mismatch
const fetchData = async () => { const fetchData = async () => {
try { try {
const [ghCommits, gtCommits, ghActivity, gtActivity] = const [ghCommits, gtCommits, ghActivity, gtActivity] =
@@ -101,7 +94,6 @@ export function RightBarContent() {
} }
}; };
// Defer API calls to next tick to allow initial render to complete first
setTimeout(() => { setTimeout(() => {
fetchData(); fetchData();
}, 0); }, 0);
@@ -168,7 +160,6 @@ export function RightBarContent() {
</ul> </ul>
</Typewriter> </Typewriter>
{/* Git Activity Section */}
<hr class="border-overlay0" /> <hr class="border-overlay0" />
<div class="flex min-w-0 flex-col gap-6 px-4 pt-6"> <div class="flex min-w-0 flex-col gap-6 px-4 pt-6">
<RecentCommits <RecentCommits
@@ -266,28 +257,22 @@ export function LeftBar() {
onMount(() => { onMount(() => {
setIsMounted(true); setIsMounted(true);
// Set up window resize listener for reactive styling
const handleResize = () => { const handleResize = () => {
setWindowWidth(window.innerWidth); setWindowWidth(window.innerWidth);
}; };
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
// Terminal-style appearance animation for "Get Lost" button
const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`"; const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`";
const originalText = "What's this?"; const originalText = "What's this?";
let glitchInterval: NodeJS.Timeout; let glitchInterval: NodeJS.Timeout;
// Delay appearance to match terminal vibe
setTimeout(() => { setTimeout(() => {
// Make visible immediately so typing animation is visible
setGetLostVisible(true); setGetLostVisible(true);
// Type-in animation with random characters resolving
let currentIndex = 0; let currentIndex = 0;
const typeInterval = setInterval(() => { const typeInterval = setInterval(() => {
if (currentIndex <= originalText.length) { if (currentIndex <= originalText.length) {
let displayText = originalText.substring(0, currentIndex); let displayText = originalText.substring(0, currentIndex);
// Add random trailing characters
if (currentIndex < originalText.length) { if (currentIndex < originalText.length) {
const remaining = originalText.length - currentIndex; const remaining = originalText.length - currentIndex;
for (let i = 0; i < remaining; i++) { for (let i = 0; i < remaining; i++) {
@@ -301,14 +286,11 @@ export function LeftBar() {
clearInterval(typeInterval); clearInterval(typeInterval);
setGetLostText(originalText); setGetLostText(originalText);
// Start regular glitch effect after typing completes
glitchInterval = setInterval(() => { glitchInterval = setInterval(() => {
if (Math.random() > 0.9) { if (Math.random() > 0.9) {
// 10% chance to glitch
let glitched = ""; let glitched = "";
for (let i = 0; i < originalText.length; i++) { for (let i = 0; i < originalText.length; i++) {
if (Math.random() > 0.7) { if (Math.random() > 0.7) {
// 30% chance each character glitches
glitched += glitched +=
glitchChars[Math.floor(Math.random() * glitchChars.length)]; glitchChars[Math.floor(Math.random() * glitchChars.length)];
} else { } else {
@@ -323,11 +305,10 @@ export function LeftBar() {
} }
}, 150); }, 150);
} }
}, 140); // Type speed (higher is slower) }, 140);
}, 500); // Initial delay before appearing }, 500);
if (ref) { if (ref) {
// Focus trap for accessibility on mobile
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const isMobile = window.innerWidth < BREAKPOINTS.MOBILE; const isMobile = window.innerWidth < BREAKPOINTS.MOBILE;
@@ -346,13 +327,11 @@ export function LeftBar() {
] as HTMLElement; ] as HTMLElement;
if (e.shiftKey) { if (e.shiftKey) {
// Shift+Tab - going backwards
if (document.activeElement === firstElement) { if (document.activeElement === firstElement) {
e.preventDefault(); e.preventDefault();
lastElement.focus(); lastElement.focus();
} }
} else { } else {
// Tab - going forwards
if (document.activeElement === lastElement) { if (document.activeElement === lastElement) {
e.preventDefault(); e.preventDefault();
firstElement.focus(); firstElement.focus();
@@ -392,12 +371,9 @@ export function LeftBar() {
}, 0); }, 0);
}); });
// Refetch user info whenever location changes
createEffect(() => { createEffect(() => {
// Track location changes
location.pathname; location.pathname;
// Only refetch if component is mounted
if (isMounted()) { if (isMounted()) {
fetchUserInfo(); fetchUserInfo();
} }
@@ -433,7 +409,6 @@ export function LeftBar() {
}} }}
style={getMainNavStyles()} style={getMainNavStyles()}
> >
{/* Hamburger menu button - positioned at right edge of navbar */}
<button <button
onClick={() => setLeftBarVisible(!leftBarVisible())} onClick={() => setLeftBarVisible(!leftBarVisible())}
class="hamburger-menu-btn absolute top-4 -right-14 z-9999 rounded-md p-2 shadow-md backdrop-blur-2xl transition-transform duration-600 ease-in-out hover:scale-110" class="hamburger-menu-btn absolute top-4 -right-14 z-9999 rounded-md p-2 shadow-md backdrop-blur-2xl transition-transform duration-600 ease-in-out hover:scale-110"
@@ -442,7 +417,7 @@ export function LeftBar() {
}} }}
aria-label="Toggle navigation menu" aria-label="Toggle navigation menu"
style={{ style={{
display: "none" // Hidden by default, shown via media query for non-touch devices display: "none"
}} }}
> >
<svg <svg
@@ -507,7 +482,6 @@ export function LeftBar() {
alt="post-cover" alt="post-cover"
class="float-right mb-1 ml-2 h-12 w-16 rounded object-cover" class="float-right mb-1 ml-2 h-12 w-16 rounded object-cover"
onError={(e) => { onError={(e) => {
// Fallback to full banner if thumbnail doesn't exist
const img = e.currentTarget; const img = e.currentTarget;
if ( if (
img.src !== img.src !==
@@ -537,7 +511,6 @@ export function LeftBar() {
</div> </div>
</div> </div>
{/* Navigation Links */}
<div class="mt-auto"> <div class="mt-auto">
<Typewriter keepAlive={false}> <Typewriter keepAlive={false}>
<ul class="flex flex-col gap-4 pt-6"> <ul class="flex flex-col gap-4 pt-6">
@@ -591,7 +564,6 @@ export function LeftBar() {
</ul> </ul>
</Typewriter> </Typewriter>
{/* Get Lost button - outside Typewriter to allow glitch effect */}
<ul class="pt-4 pb-6"> <ul class="pt-4 pb-6">
<li <li
class="hover:text-subtext0 w-fit transition-all duration-500 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold" class="hover:text-subtext0 w-fit transition-all duration-500 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"

View File

@@ -38,7 +38,6 @@ export function Btop(props: BtopProps) {
const [isMobile, setIsMobile] = createSignal(false); const [isMobile, setIsMobile] = createSignal(false);
onMount(() => { onMount(() => {
// Check if mobile
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE); setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE);
@@ -49,7 +48,6 @@ export function Btop(props: BtopProps) {
onCleanup(() => window.removeEventListener("resize", handleResize)); onCleanup(() => window.removeEventListener("resize", handleResize));
} }
// Animate CPU usage
const cpuInterval = setInterval(() => { const cpuInterval = setInterval(() => {
setCpuUsage((prev) => { setCpuUsage((prev) => {
const change = (Math.random() - 0.5) * 10; const change = (Math.random() - 0.5) * 10;
@@ -58,7 +56,6 @@ export function Btop(props: BtopProps) {
}); });
}, 1000); }, 1000);
// Animate memory usage
const memInterval = setInterval(() => { const memInterval = setInterval(() => {
setMemUsage((prev) => { setMemUsage((prev) => {
const change = (Math.random() - 0.5) * 5; const change = (Math.random() - 0.5) * 5;
@@ -67,13 +64,11 @@ export function Btop(props: BtopProps) {
}); });
}, 1500); }, 1500);
// Animate network
const netInterval = setInterval(() => { const netInterval = setInterval(() => {
setNetDown(Math.floor(Math.random() * 1000)); setNetDown(Math.floor(Math.random() * 1000));
setNetUp(Math.floor(Math.random() * 100)); setNetUp(Math.floor(Math.random() * 100));
}, 800); }, 800);
// Animate processes
const procInterval = setInterval(() => { const procInterval = setInterval(() => {
setProcesses((prev) => setProcesses((prev) =>
prev.map((proc) => ({ prev.map((proc) => ({
@@ -90,12 +85,10 @@ export function Btop(props: BtopProps) {
); );
}, 2000); }, 2000);
// Keyboard handler for :q
const handleKeyPress = (e: KeyboardEvent) => { const handleKeyPress = (e: KeyboardEvent) => {
if (!isMobile() && e.key === "q" && e.shiftKey && e.key === ":") { if (!isMobile() && e.key === "q" && e.shiftKey && e.key === ":") {
props.onClose(); props.onClose();
} }
// Simple 'q' press to quit
if (!isMobile() && e.key === "q") { if (!isMobile() && e.key === "q") {
props.onClose(); props.onClose();
} }
@@ -120,9 +113,7 @@ export function Btop(props: BtopProps) {
return ( return (
<div class="bg-crust fixed inset-0 z-[10000] flex items-center justify-center p-4"> <div class="bg-crust fixed inset-0 z-[10000] flex items-center justify-center p-4">
{/* Main btop container */}
<div class="bg-mantle border-surface0 text-text relative h-full w-full max-w-6xl overflow-hidden rounded-lg border-2 font-mono text-sm shadow-2xl md:h-auto md:max-h-[90vh]"> <div class="bg-mantle border-surface0 text-text relative h-full w-full max-w-6xl overflow-hidden rounded-lg border-2 font-mono text-sm shadow-2xl md:h-auto md:max-h-[90vh]">
{/* Header */}
<div class="border-surface0 bg-surface0 border-b px-4 py-2"> <div class="border-surface0 bg-surface0 border-b px-4 py-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-blue"> <span class="text-blue">
@@ -145,13 +136,10 @@ export function Btop(props: BtopProps) {
</div> </div>
</div> </div>
{/* Content */}
<div class="space-y-4 p-4"> <div class="space-y-4 p-4">
{/* System Stats */}
<div class="border-surface0 bg-base rounded border p-3"> <div class="border-surface0 bg-base rounded border p-3">
<div class="text-green mb-2 font-bold">System Resources</div> <div class="text-green mb-2 font-bold">System Resources</div>
<div class="space-y-2"> <div class="space-y-2">
{/* CPU */}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-subtext1 w-12">CPU</span> <span class="text-subtext1 w-12">CPU</span>
<span class="text-blue">[{createBar(cpuUsage())}]</span> <span class="text-blue">[{createBar(cpuUsage())}]</span>
@@ -159,7 +147,6 @@ export function Btop(props: BtopProps) {
<span class="text-subtext0">2.4 GHz</span> <span class="text-subtext0">2.4 GHz</span>
</div> </div>
{/* Memory */}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-subtext1 w-12">MEM</span> <span class="text-subtext1 w-12">MEM</span>
<span class="text-blue">[{createBar(memUsage())}]</span> <span class="text-blue">[{createBar(memUsage())}]</span>
@@ -169,7 +156,6 @@ export function Btop(props: BtopProps) {
</span> </span>
</div> </div>
{/* Network */}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-subtext1 w-12">NET</span> <span class="text-subtext1 w-12">NET</span>
<span class="text-green"> {netDown()} KB/s</span> <span class="text-green"> {netDown()} KB/s</span>
@@ -178,7 +164,6 @@ export function Btop(props: BtopProps) {
</div> </div>
</div> </div>
{/* Process List */}
<div class="border-surface0 bg-base rounded border"> <div class="border-surface0 bg-base rounded border">
<div class="border-surface0 border-b px-3 py-2"> <div class="border-surface0 border-b px-3 py-2">
<span class="text-green font-bold">Processes</span> <span class="text-green font-bold">Processes</span>
@@ -224,7 +209,6 @@ export function Btop(props: BtopProps) {
</div> </div>
</div> </div>
{/* Footer info */}
<div class="text-subtext1 text-center text-xs"> <div class="text-subtext1 text-center text-xs">
<Show <Show
when={!isMobile()} when={!isMobile()}
@@ -238,7 +222,6 @@ export function Btop(props: BtopProps) {
</div> </div>
</div> </div>
{/* Overlay background */}
<div <div
class="absolute inset-0 -z-10 bg-black/80 backdrop-blur-sm" class="absolute inset-0 -z-10 bg-black/80 backdrop-blur-sm"
onClick={props.onClose} onClick={props.onClose}

View File

@@ -19,7 +19,6 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
props.initialRemainingTime ?? props.duration props.initialRemainingTime ?? props.duration
); );
// Calculate progress (0 to 1)
const progress = () => remainingTime() / props.duration; const progress = () => remainingTime() / props.duration;
const strokeDashoffset = () => circumference * (1 - progress()); const strokeDashoffset = () => circumference * (1 - progress());
@@ -62,7 +61,6 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
height={props.size} height={props.size}
style={{ transform: "rotate(-90deg)" }} style={{ transform: "rotate(-90deg)" }}
> >
{/* Background circle */}
<circle <circle
cx={props.size / 2} cx={props.size / 2}
cy={props.size / 2} cy={props.size / 2}
@@ -71,7 +69,6 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
stroke="#e5e7eb" stroke="#e5e7eb"
stroke-width={props.strokeWidth} stroke-width={props.strokeWidth}
/> />
{/* Progress circle */}
<circle <circle
cx={props.size / 2} cx={props.size / 2}
cy={props.size / 2} cy={props.size / 2}
@@ -84,7 +81,6 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
stroke-linecap="round" stroke-linecap="round"
/> />
</svg> </svg>
{/* Timer text in center */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",

View File

@@ -28,17 +28,14 @@ export default function CustomScrollbar(props: CustomScrollbarProps) {
const scrollHeight = containerRef.scrollHeight; const scrollHeight = containerRef.scrollHeight;
const clientHeight = containerRef.clientHeight; const clientHeight = containerRef.clientHeight;
// Calculate thumb height as percentage of visible area
const viewportRatio = clientHeight / scrollHeight; const viewportRatio = clientHeight / scrollHeight;
const calculatedThumbHeight = Math.max(viewportRatio * 100, 5); const calculatedThumbHeight = Math.max(viewportRatio * 100, 5);
setThumbHeight(calculatedThumbHeight); setThumbHeight(calculatedThumbHeight);
// Calculate scroll percentage
const maxScroll = scrollHeight - clientHeight; const maxScroll = scrollHeight - clientHeight;
const percentage = maxScroll > 0 ? (scrollTop / maxScroll) * 100 : 0; const percentage = maxScroll > 0 ? (scrollTop / maxScroll) * 100 : 0;
setScrollPercentage(percentage); setScrollPercentage(percentage);
// Show scrollbar on scroll if autoHide enabled
if (props.autoHide) { if (props.autoHide) {
setIsVisible(true); setIsVisible(true);
clearTimeout(hideTimeout); clearTimeout(hideTimeout);
@@ -106,10 +103,8 @@ export default function CustomScrollbar(props: CustomScrollbarProps) {
onMount(() => { onMount(() => {
if (!containerRef) return; if (!containerRef) return;
// Initial update
updateScrollbar(); updateScrollbar();
// Update after delays to catch dynamically loaded content
setTimeout(() => updateScrollbar(), 100); setTimeout(() => updateScrollbar(), 100);
setTimeout(() => updateScrollbar(), 500); setTimeout(() => updateScrollbar(), 500);
@@ -118,7 +113,6 @@ export default function CustomScrollbar(props: CustomScrollbarProps) {
updateScrollbar(); updateScrollbar();
}; };
// Debounced mutation observer
let mutationTimeout: NodeJS.Timeout; let mutationTimeout: NodeJS.Timeout;
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
clearTimeout(mutationTimeout); clearTimeout(mutationTimeout);
@@ -132,7 +126,6 @@ export default function CustomScrollbar(props: CustomScrollbarProps) {
subtree: true subtree: true
}); });
// Use passive scroll listener for better performance
containerRef.addEventListener("scroll", updateScrollbar, { passive: true }); containerRef.addEventListener("scroll", updateScrollbar, { passive: true });
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
@@ -159,7 +152,6 @@ export default function CustomScrollbar(props: CustomScrollbarProps) {
"-ms-overflow-style": "none" "-ms-overflow-style": "none"
}} }}
> >
{/* Hide default scrollbar */}
<style> <style>
{` {`
div::-webkit-scrollbar { div::-webkit-scrollbar {
@@ -170,7 +162,6 @@ export default function CustomScrollbar(props: CustomScrollbarProps) {
{props.children} {props.children}
{/* Custom scrollbar */}
<Show when={thumbHeight() < 100}> <Show when={thumbHeight() < 100}>
<div <div
ref={scrollbarRef} ref={scrollbarRef}

View File

@@ -9,11 +9,9 @@ export default function DeletionForm() {
const [error, setError] = createSignal(""); const [error, setError] = createSignal("");
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
// Form ref
let emailRef: HTMLInputElement | undefined; let emailRef: HTMLInputElement | undefined;
let timerInterval: number | undefined; let timerInterval: number | undefined;
// Calculate remaining time from cookie
const calcRemainder = (timer: string) => { const calcRemainder = (timer: string) => {
const expires = new Date(timer); const expires = new Date(timer);
const remaining = expires.getTime() - Date.now(); const remaining = expires.getTime() - Date.now();
@@ -92,7 +90,6 @@ export default function DeletionForm() {
} }
}; };
// Countdown timer render function
const renderTime = ({ remainingTime }: { remainingTime: number }) => { const renderTime = ({ remainingTime }: { remainingTime: number }) => {
return ( return (
<div class="timer"> <div class="timer">

View File

@@ -20,13 +20,11 @@ export default function ErrorBoundaryFallback(
}; };
} }
// Try to get dark mode, fallback to a function returning true (dark) if context unavailable
let isDark: () => boolean; let isDark: () => boolean;
try { try {
const darkMode = useDarkMode(); const darkMode = useDarkMode();
isDark = darkMode.isDark; isDark = darkMode.isDark;
} catch (e) { } catch (e) {
// Context not available, use default
isDark = () => true; isDark = () => true;
} }

View File

@@ -68,7 +68,6 @@ export default function PasswordStrengthMeter(
}); });
} }
// Always show special character as optional/recommended
reqs.push({ reqs.push({
label: "One special character\n(recommended)", label: "One special character\n(recommended)",
test: (pwd) => /[^A-Za-z0-9]/.test(pwd), test: (pwd) => /[^A-Za-z0-9]/.test(pwd),

View File

@@ -52,7 +52,6 @@ function ParallaxLayer(props: ParallaxLayerProps) {
"will-change": "transform" "will-change": "transform"
})); }));
// Set up animation when component mounts or when direction/speed changes
createEffect(() => { createEffect(() => {
if (!containerRef) return; if (!containerRef) return;

View File

@@ -39,7 +39,6 @@ export function TerminalErrorPage(props: TerminalErrorPageProps) {
let inputRef: HTMLInputElement | undefined; let inputRef: HTMLInputElement | undefined;
let footerRef: HTMLDivElement | undefined; let footerRef: HTMLDivElement | undefined;
// Auto-scroll to bottom when history changes
createEffect(() => { createEffect(() => {
if (history().length > 0) { if (history().length > 0) {
setTimeout(() => { setTimeout(() => {

View File

@@ -21,7 +21,6 @@ export function Typewriter(props: {
containerRef.style.position = "relative"; containerRef.style.position = "relative";
// FIRST: Walk DOM and split text into character spans
const textNodes: { node: Text; text: string; startIndex: number }[] = []; const textNodes: { node: Text; text: string; startIndex: number }[] = [];
let totalChars = 0; let totalChars = 0;
@@ -36,12 +35,10 @@ export function Typewriter(props: {
}); });
totalChars += text.length; totalChars += text.length;
// Replace text with spans for each character
const span = document.createElement("span"); const span = document.createElement("span");
text.split("").forEach((char, i) => { text.split("").forEach((char, i) => {
const charSpan = document.createElement("span"); const charSpan = document.createElement("span");
charSpan.textContent = char; charSpan.textContent = char;
// Don't set opacity here - CSS will handle it based on data-typewriter state
charSpan.setAttribute( charSpan.setAttribute(
"data-char-index", "data-char-index",
String(totalChars - text.length + i) String(totalChars - text.length + i)
@@ -57,21 +54,18 @@ export function Typewriter(props: {
walkDOM(containerRef); walkDOM(containerRef);
// Mark as animated AFTER DOM manipulation - this triggers CSS to hide characters
setAnimated(true); setAnimated(true);
containerRef.setAttribute("data-typewriter-ready", "true"); containerRef.setAttribute("data-typewriter-ready", "true");
// Listen for animation end to hide cursor
const handleAnimationEnd = () => { const handleAnimationEnd = () => {
setShouldHide(true); setShouldHide(true);
cursorRef?.removeEventListener("animationend", handleAnimationEnd); cursorRef?.removeEventListener("animationend", handleAnimationEnd);
}; };
const startReveal = () => { const startReveal = () => {
setIsTyping(true); // Switch to typing cursor setIsTyping(true);
// Animate revealing characters
let currentIndex = 0; let currentIndex = 0;
const speed = props.speed || 30; const speed = props.speed || 30;
@@ -88,7 +82,6 @@ export function Typewriter(props: {
const rect = charSpan.getBoundingClientRect(); const rect = charSpan.getBoundingClientRect();
const containerRect = containerRef.getBoundingClientRect(); const containerRect = containerRef.getBoundingClientRect();
// Position cursor at the end of the current character
cursorRef.style.left = `${rect.right - containerRect.left}px`; cursorRef.style.left = `${rect.right - containerRect.left}px`;
cursorRef.style.top = `${rect.top - containerRect.top}px`; cursorRef.style.top = `${rect.top - containerRect.top}px`;
cursorRef.style.height = `${charSpan.offsetHeight}px`; cursorRef.style.height = `${charSpan.offsetHeight}px`;
@@ -98,15 +91,11 @@ export function Typewriter(props: {
currentIndex++; currentIndex++;
setTimeout(revealNextChar, 1000 / speed); setTimeout(revealNextChar, 1000 / speed);
} else { } else {
// Typing finished, switch to block cursor
setIsTyping(false); setIsTyping(false);
// Start keepAlive timer if it's a number
if (typeof keepAlive === "number") { if (typeof keepAlive === "number") {
// Attach animation end listener
cursorRef?.addEventListener("animationend", handleAnimationEnd); cursorRef?.addEventListener("animationend", handleAnimationEnd);
// Trigger the animation with finite iteration count
const durationSeconds = keepAlive / 1000; const durationSeconds = keepAlive / 1000;
const iterations = Math.ceil(durationSeconds); const iterations = Math.ceil(durationSeconds);
if (cursorRef) { if (cursorRef) {

View File

@@ -25,16 +25,13 @@ export default function CommentBlock(props: CommentBlockProps) {
const [deletionLoading, setDeletionLoading] = createSignal(false); const [deletionLoading, setDeletionLoading] = createSignal(false);
const [userData, setUserData] = createSignal<UserPublicData | null>(null); const [userData, setUserData] = createSignal<UserPublicData | null>(null);
// Refs
let containerRef: HTMLDivElement | undefined; let containerRef: HTMLDivElement | undefined;
let commentInputRef: HTMLDivElement | undefined; let commentInputRef: HTMLDivElement | undefined;
// Auto-collapse at level 4+
createEffect(() => { createEffect(() => {
setCommentCollapsed(props.level >= 4); setCommentCollapsed(props.level >= 4);
}); });
// Find user data from comment map
createEffect(() => { createEffect(() => {
if (props.userCommentMap) { if (props.userCommentMap) {
props.userCommentMap.forEach((commentIds, user) => { props.userCommentMap.forEach((commentIds, user) => {
@@ -45,23 +42,19 @@ export default function CommentBlock(props: CommentBlockProps) {
} }
}); });
// Update toggle height based on container size
createEffect(() => { createEffect(() => {
if (containerRef) { if (containerRef) {
const correction = showingReactionOptions() ? 80 : 48; const correction = showingReactionOptions() ? 80 : 48;
setToggleHeight(containerRef.clientHeight + correction); setToggleHeight(containerRef.clientHeight + correction);
} }
// Trigger on these dependencies
windowWidth(); windowWidth();
showingReactionOptions(); showingReactionOptions();
}); });
// Update reactions from map
createEffect(() => { createEffect(() => {
setReactions(props.reactionMap.get(props.comment.id) || []); setReactions(props.reactionMap.get(props.comment.id) || []);
}); });
// Event handlers
const collapseCommentToggle = (e: MouseEvent) => { const collapseCommentToggle = (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@@ -112,7 +105,6 @@ export default function CommentBlock(props: CommentBlockProps) {
); );
}; };
// Computed values
const upvoteCount = () => const upvoteCount = () =>
reactions().filter((r) => r.type === "upVote").length; reactions().filter((r) => r.type === "upVote").length;

View File

@@ -4,7 +4,6 @@ import type { CommentInputBlockProps } from "~/types/comment";
export default function CommentInputBlock(props: CommentInputBlockProps) { export default function CommentInputBlock(props: CommentInputBlockProps) {
let bodyRef: HTMLTextAreaElement | undefined; let bodyRef: HTMLTextAreaElement | undefined;
// Clear the textarea when comment is submitted
createEffect(() => { createEffect(() => {
if (!props.commentSubmitLoading && bodyRef) { if (!props.commentSubmitLoading && bodyRef) {
bodyRef.value = ""; bodyRef.value = "";

View File

@@ -8,14 +8,12 @@ export default function CommentSorting(props: CommentSortingProps) {
new Map(props.topLevelComments?.map((comment) => [comment.id, true])) new Map(props.topLevelComments?.map((comment) => [comment.id, true]))
); );
// Update showing block when top level comments change
createEffect(() => { createEffect(() => {
setShowingBlock( setShowingBlock(
new Map(props.topLevelComments?.map((comment) => [comment.id, true])) new Map(props.topLevelComments?.map((comment) => [comment.id, true]))
); );
}); });
// Reset clickedOnce after timeout
createEffect(() => { createEffect(() => {
if (clickedOnce()) { if (clickedOnce()) {
setTimeout(() => setClickedOnce(false), 300); setTimeout(() => setClickedOnce(false), 300);
@@ -34,7 +32,6 @@ export default function CommentSorting(props: CommentSortingProps) {
} }
}; };
// Comments are already sorted from server, no need for client-side sorting
return ( return (
<For each={props.topLevelComments}> <For each={props.topLevelComments}>
{(topLevelComment) => ( {(topLevelComment) => (

View File

@@ -27,7 +27,6 @@ export default function CommentSortingSelect(props: CommentSortingSelectProps) {
props.setSorting(mode); props.setSorting(mode);
setIsOpen(false); setIsOpen(false);
// Update URL with sortBy parameter
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("sortBy", mode); url.searchParams.set("sortBy", mode);
navigate(`${location.pathname}?${url.searchParams.toString()}#comments`, { navigate(`${location.pathname}?${url.searchParams.toString()}#comments`, {

View File

@@ -18,7 +18,6 @@ export default function DeletePostButton(props: DeletePostButtonProps) {
setLoading(true); setLoading(true);
try { try {
await api.database.deletePost.mutate({ id: props.postID }); await api.database.deletePost.mutate({ id: props.postID });
// Refresh the page after successful deletion
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
alert("Failed to delete post"); alert("Failed to delete post");

View File

@@ -32,14 +32,12 @@ export default function MermaidRenderer() {
const id = `mermaid-${index}-${Math.random().toString(36).substr(2, 9)}`; const id = `mermaid-${index}-${Math.random().toString(36).substr(2, 9)}`;
const { svg } = await mermaid.render(id, content); const { svg } = await mermaid.render(id, content);
// Replace the pre/code with rendered SVG
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "mermaid-rendered"; wrapper.className = "mermaid-rendered";
wrapper.innerHTML = svg; wrapper.innerHTML = svg;
pre.replaceWith(wrapper); pre.replaceWith(wrapper);
} catch (err) { } catch (err) {
console.error("Failed to render mermaid diagram:", err); console.error("Failed to render mermaid diagram:", err);
// Keep the original code block if rendering fails
pre.classList.add("mermaid-error"); pre.classList.add("mermaid-error");
} }
}); });

View File

@@ -105,10 +105,8 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const pre = codeBlock.parentElement; const pre = codeBlock.parentElement;
if (!pre) return; if (!pre) return;
// Skip mermaid diagrams
if (pre.dataset.type === "mermaid") return; if (pre.dataset.type === "mermaid") return;
// Check if already processed (has header with copy button)
const existingHeader = pre.previousElementSibling; const existingHeader = pre.previousElementSibling;
if ( if (
existingHeader?.classList.contains("language-header") && existingHeader?.classList.contains("language-header") &&
@@ -117,53 +115,43 @@ export default function PostBodyClient(props: PostBodyClientProps) {
return; return;
} }
// Set off-black background for code block
pre.style.backgroundColor = "#1a1a1a"; pre.style.backgroundColor = "#1a1a1a";
// Extract language from code block classes
const classes = Array.from(codeBlock.classList); const classes = Array.from(codeBlock.classList);
const languageClass = classes.find((cls) => cls.startsWith("language-")); const languageClass = classes.find((cls) => cls.startsWith("language-"));
const language = languageClass?.replace("language-", "") || ""; const language = languageClass?.replace("language-", "") || "";
// Create language header if language is detected
if (language) { if (language) {
const languageHeader = document.createElement("div"); const languageHeader = document.createElement("div");
languageHeader.className = "language-header"; languageHeader.className = "language-header";
languageHeader.style.backgroundColor = "#1a1a1a"; languageHeader.style.backgroundColor = "#1a1a1a";
// Add language label
const languageLabel = document.createElement("span"); const languageLabel = document.createElement("span");
languageLabel.textContent = language; languageLabel.textContent = language;
languageHeader.appendChild(languageLabel); languageHeader.appendChild(languageLabel);
// Create copy button in header
const copyButton = document.createElement("button"); const copyButton = document.createElement("button");
copyButton.className = "copy-button"; copyButton.className = "copy-button";
copyButton.textContent = "Copy"; copyButton.textContent = "Copy";
copyButton.dataset.codeBlock = "true"; copyButton.dataset.codeBlock = "true";
// Store reference to the code block for copying
copyButton.dataset.codeBlockId = `code-${Math.random().toString(36).substr(2, 9)}`; copyButton.dataset.codeBlockId = `code-${Math.random().toString(36).substr(2, 9)}`;
codeBlock.dataset.codeBlockId = copyButton.dataset.codeBlockId; codeBlock.dataset.codeBlockId = copyButton.dataset.codeBlockId;
languageHeader.appendChild(copyButton); languageHeader.appendChild(copyButton);
// Insert header before pre element
pre.parentElement?.insertBefore(languageHeader, pre); pre.parentElement?.insertBefore(languageHeader, pre);
} }
// Add line numbers
const codeText = codeBlock.textContent || ""; const codeText = codeBlock.textContent || "";
const lines = codeText.split("\n"); const lines = codeText.split("\n");
const lineCount = const lineCount =
lines[lines.length - 1] === "" ? lines.length - 1 : lines.length; lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
if (lineCount > 0 && !pre.querySelector(".line-numbers")) { if (lineCount > 0 && !pre.querySelector(".line-numbers")) {
// Create line numbers container
const lineNumbers = document.createElement("div"); const lineNumbers = document.createElement("div");
lineNumbers.className = "line-numbers"; lineNumbers.className = "line-numbers";
// Generate line numbers
for (let i = 1; i <= lineCount; i++) { for (let i = 1; i <= lineCount; i++) {
const lineNum = document.createElement("div"); const lineNum = document.createElement("div");
lineNum.textContent = i.toString(); lineNum.textContent = i.toString();
@@ -214,7 +202,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
}); });
// Look for the references section marker to get the custom heading name
const marker = contentRef.querySelector( const marker = contentRef.querySelector(
"span[id='references-section-start']" "span[id='references-section-start']"
) as HTMLElement | null; ) as HTMLElement | null;
@@ -233,13 +220,11 @@ export default function PostBodyClient(props: PostBodyClientProps) {
if (referencesSection) { if (referencesSection) {
referencesSection.className = "text-2xl font-bold mb-4 text-text"; referencesSection.className = "text-2xl font-bold mb-4 text-text";
// Find the parent container and add styling
const parentDiv = referencesSection.parentElement; const parentDiv = referencesSection.parentElement;
if (parentDiv) { if (parentDiv) {
parentDiv.classList.add("references-heading"); parentDiv.classList.add("references-heading");
} }
// Find all paragraphs after the References heading that start with [n]
let currentElement = referencesSection.nextElementSibling; let currentElement = referencesSection.nextElementSibling;
while (currentElement) { while (currentElement) {
@@ -251,29 +236,22 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const refNumber = match[1]; const refNumber = match[1];
const refId = `ref-${refNumber}`; const refId = `ref-${refNumber}`;
// Set the ID for linking
currentElement.id = refId; currentElement.id = refId;
// Add styling
currentElement.className = currentElement.className =
"reference-item transition-colors duration-500 text-sm mb-3"; "reference-item transition-colors duration-500 text-sm mb-3";
// Parse and style the content - get everything after [n]
let refText = text.substring(match[0].length); let refText = text.substring(match[0].length);
// Remove any existing "↑ Back" text (including various Unicode arrow variants)
refText = refText.replace(/[↑⬆️]\s*Back\s*$/i, "").trim(); refText = refText.replace(/[↑⬆️]\s*Back\s*$/i, "").trim();
// Create styled content
currentElement.innerHTML = ""; currentElement.innerHTML = "";
// Add bold reference number
const refNumSpan = document.createElement("span"); const refNumSpan = document.createElement("span");
refNumSpan.className = "text-blue font-semibold"; refNumSpan.className = "text-blue font-semibold";
refNumSpan.textContent = `[${refNumber}]`; refNumSpan.textContent = `[${refNumber}]`;
currentElement.appendChild(refNumSpan); currentElement.appendChild(refNumSpan);
// Add reference text
if (refText) { if (refText) {
const refTextSpan = document.createElement("span"); const refTextSpan = document.createElement("span");
refTextSpan.className = "ml-2"; refTextSpan.className = "ml-2";
@@ -286,7 +264,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
currentElement.appendChild(refTextSpan); currentElement.appendChild(refTextSpan);
} }
// Add back button
const backLink = document.createElement("a"); const backLink = document.createElement("a");
backLink.href = `#ref-${refNumber}-back`; backLink.href = `#ref-${refNumber}-back`;
backLink.className = backLink.className =
@@ -297,7 +274,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const target = document.getElementById(`ref-${refNumber}-back`); const target = document.getElementById(`ref-${refNumber}-back`);
if (target) { if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" }); target.scrollIntoView({ behavior: "smooth", block: "center" });
// Highlight the reference link briefly
target.style.backgroundColor = "rgba(203, 166, 247, 0.2)"; target.style.backgroundColor = "rgba(203, 166, 247, 0.2)";
setTimeout(() => { setTimeout(() => {
target.style.backgroundColor = ""; target.style.backgroundColor = "";
@@ -308,7 +284,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
} }
// Check if we've reached another heading (end of references)
if ( if (
currentElement.tagName.match(/^H[1-6]$/) && currentElement.tagName.match(/^H[1-6]$/) &&
currentElement !== referencesSection currentElement !== referencesSection
@@ -321,14 +296,12 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
}; };
// Load highlight.js only when needed
createEffect(() => { createEffect(() => {
if (props.hasCodeBlock && !hljs()) { if (props.hasCodeBlock && !hljs()) {
loadHighlightJS().then(setHljs); loadHighlightJS().then(setHljs);
} }
}); });
// Apply syntax highlighting when hljs loads and when body changes
createEffect(() => { createEffect(() => {
const hljsInstance = hljs(); const hljsInstance = hljs();
if (hljsInstance && props.hasCodeBlock && contentRef) { if (hljsInstance && props.hasCodeBlock && contentRef) {
@@ -339,7 +312,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
}); });
// Process references after content is mounted and when body changes
onMount(() => { onMount(() => {
setTimeout(() => { setTimeout(() => {
processReferences(); processReferences();
@@ -348,14 +320,11 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
}, 150); }, 150);
// Event delegation for copy buttons (single listener for all buttons)
if (contentRef) { if (contentRef) {
const handleCopyButtonInteraction = async (e: Event) => { const handleCopyButtonInteraction = async (e: Event) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// Handle click
if (e.type === "click" && target.classList.contains("copy-button")) { if (e.type === "click" && target.classList.contains("copy-button")) {
// Find the code block using the stored ID
const codeBlockId = target.dataset.codeBlockId; const codeBlockId = target.dataset.codeBlockId;
const codeBlock = codeBlockId const codeBlock = codeBlockId
? contentRef?.querySelector( ? contentRef?.querySelector(
@@ -389,13 +358,11 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
}; };
// Single event listener for all copy button interactions
contentRef.addEventListener("click", handleCopyButtonInteraction); contentRef.addEventListener("click", handleCopyButtonInteraction);
} }
}); });
createEffect(() => { createEffect(() => {
// Re-process when body changes
if (props.body && contentRef) { if (props.body && contentRef) {
setTimeout(() => { setTimeout(() => {
processReferences(); processReferences();

View File

@@ -21,7 +21,6 @@ export default function PostSorting(props: PostSortingProps) {
const filteredPosts = createMemo(() => { const filteredPosts = createMemo(() => {
let filtered = props.posts; let filtered = props.posts;
// Apply publication status filter (admin only)
if (props.privilegeLevel === "admin" && props.status) { if (props.privilegeLevel === "admin" && props.status) {
if (props.status === "published") { if (props.status === "published") {
filtered = filtered.filter((post) => post.published === 1); filtered = filtered.filter((post) => post.published === 1);
@@ -30,24 +29,20 @@ export default function PostSorting(props: PostSortingProps) {
} }
} }
// Build map of post_id -> tags for that post
const postTags = new Map<number, Set<string>>(); const postTags = new Map<number, Set<string>>();
props.tags.forEach((tag) => { props.tags.forEach((tag) => {
if (!postTags.has(tag.post_id)) { if (!postTags.has(tag.post_id)) {
postTags.set(tag.post_id, new Set()); postTags.set(tag.post_id, new Set());
} }
// Tag values in DB have # prefix, remove it for comparison
const tagWithoutHash = tag.value.startsWith("#") const tagWithoutHash = tag.value.startsWith("#")
? tag.value.slice(1) ? tag.value.slice(1)
: tag.value; : tag.value;
postTags.get(tag.post_id)!.add(tagWithoutHash); postTags.get(tag.post_id)!.add(tagWithoutHash);
}); });
// WHITELIST MODE: Only show posts that have at least one of the included tags
if (props.include !== undefined) { if (props.include !== undefined) {
const includeList = props.include.split("|").filter(Boolean); const includeList = props.include.split("|").filter(Boolean);
// Empty whitelist means show nothing
if (includeList.length === 0) { if (includeList.length === 0) {
return []; return [];
} }
@@ -58,7 +53,6 @@ export default function PostSorting(props: PostSortingProps) {
const tags = postTags.get(post.id); const tags = postTags.get(post.id);
if (!tags || tags.size === 0) return false; if (!tags || tags.size === 0) return false;
// Post must have at least one tag from the include list
for (const tag of tags) { for (const tag of tags) {
if (includeSet.has(tag)) { if (includeSet.has(tag)) {
return true; return true;
@@ -68,11 +62,9 @@ export default function PostSorting(props: PostSortingProps) {
}); });
} }
// BLACKLIST MODE: Hide posts that have ANY of the filtered tags
if (props.filters !== undefined) { if (props.filters !== undefined) {
const filterList = props.filters.split("|").filter(Boolean); const filterList = props.filters.split("|").filter(Boolean);
// Empty blacklist means show everything
if (filterList.length === 0) { if (filterList.length === 0) {
return filtered; return filtered;
} }
@@ -81,19 +73,17 @@ export default function PostSorting(props: PostSortingProps) {
return filtered.filter((post) => { return filtered.filter((post) => {
const tags = postTags.get(post.id); const tags = postTags.get(post.id);
if (!tags || tags.size === 0) return true; // Show posts with no tags if (!tags || tags.size === 0) return true;
// Post must NOT have any blacklisted tags
for (const tag of tags) { for (const tag of tags) {
if (filterSet.has(tag)) { if (filterSet.has(tag)) {
return false; // Hide this post return false;
} }
} }
return true; // Show this post return true;
}); });
} }
// No filters: show all posts
return filtered; return filtered;
}); });
@@ -105,7 +95,6 @@ export default function PostSorting(props: PostSortingProps) {
sorted.reverse(); // Posts come oldest first from DB sorted.reverse(); // Posts come oldest first from DB
break; break;
case "oldest": case "oldest":
// Already in oldest order from DB
break; break;
case "most_liked": case "most_liked":
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0)); sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0));

View File

@@ -19,7 +19,6 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Derive selected from URL params instead of local state
const selected = () => { const selected = () => {
const sortParam = searchParams.sort || "newest"; const sortParam = searchParams.sort || "newest";
return sorting.find((s) => s.val === sortParam) || sorting[0]; return sorting.find((s) => s.val === sortParam) || sorting[0];
@@ -28,7 +27,6 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
const handleSelect = (sort: { val: string; label: string }) => { const handleSelect = (sort: { val: string; label: string }) => {
setIsOpen(false); setIsOpen(false);
// Build new URL preserving all existing params
const params = new URLSearchParams(searchParams as Record<string, string>); const params = new URLSearchParams(searchParams as Record<string, string>);
params.set("sort", sort.val); params.set("sort", sort.val);

View File

@@ -29,7 +29,6 @@ export default function TagSelector(props: TagSelectorProps) {
const currentInclude = () => const currentInclude = () =>
searchParams.include?.split("|").filter(Boolean) || []; searchParams.include?.split("|").filter(Boolean) || [];
// Get currently selected tags based on mode
const selectedTags = () => { const selectedTags = () => {
if (filterMode() === "whitelist") { if (filterMode() === "whitelist") {
return currentInclude(); return currentInclude();
@@ -38,14 +37,12 @@ export default function TagSelector(props: TagSelectorProps) {
} }
}; };
// Sync filter mode with URL params and ensure one is always present
createEffect(() => { createEffect(() => {
if ("include" in searchParams) { if ("include" in searchParams) {
setFilterMode("whitelist"); setFilterMode("whitelist");
} else if ("filter" in searchParams) { } else if ("filter" in searchParams) {
setFilterMode("blacklist"); setFilterMode("blacklist");
} else { } else {
// No filter param exists, default to blacklist mode with empty filter
const params = new URLSearchParams( const params = new URLSearchParams(
searchParams as Record<string, string> searchParams as Record<string, string>
); );
@@ -66,7 +63,6 @@ export default function TagSelector(props: TagSelectorProps) {
Object.keys(props.tagMap).map((key) => key.slice(1)) Object.keys(props.tagMap).map((key) => key.slice(1))
); );
// Check if a tag is currently selected
const isTagChecked = (tag: string) => { const isTagChecked = (tag: string) => {
return selectedTags().includes(tag); return selectedTags().includes(tag);
}; };
@@ -106,26 +102,21 @@ export default function TagSelector(props: TagSelectorProps) {
let newSelected: string[]; let newSelected: string[];
if (isChecked) { if (isChecked) {
// Add tag to selection
newSelected = [...currentSelected, tag]; newSelected = [...currentSelected, tag];
} else { } else {
// Remove tag from selection
newSelected = currentSelected.filter((t) => t !== tag); newSelected = currentSelected.filter((t) => t !== tag);
} }
// Build URL preserving all existing params
const params = new URLSearchParams(searchParams as Record<string, string>); const params = new URLSearchParams(searchParams as Record<string, string>);
const paramName = filterMode() === "whitelist" ? "include" : "filter"; const paramName = filterMode() === "whitelist" ? "include" : "filter";
const otherParamName = filterMode() === "whitelist" ? "filter" : "include"; const otherParamName = filterMode() === "whitelist" ? "filter" : "include";
// Remove the other mode's param
params.delete(otherParamName); params.delete(otherParamName);
if (newSelected.length > 0) { if (newSelected.length > 0) {
const paramValue = newSelected.join("|"); const paramValue = newSelected.join("|");
params.set(paramName, paramValue); params.set(paramName, paramValue);
} else { } else {
// Keep empty param to preserve mode (especially important for whitelist)
params.set(paramName, ""); params.set(paramName, "");
} }
@@ -137,14 +128,11 @@ export default function TagSelector(props: TagSelectorProps) {
const paramName = filterMode() === "whitelist" ? "include" : "filter"; const paramName = filterMode() === "whitelist" ? "include" : "filter";
const otherParamName = filterMode() === "whitelist" ? "filter" : "include"; const otherParamName = filterMode() === "whitelist" ? "filter" : "include";
// Remove the other mode's param
params.delete(otherParamName); params.delete(otherParamName);
if (allChecked()) { if (allChecked()) {
// Uncheck all: keep empty param to preserve mode
params.set(paramName, ""); params.set(paramName, "");
} else { } else {
// Check all: select all tags
const allTags = allTagKeys().join("|"); const allTags = allTagKeys().join("|");
params.set(paramName, allTags); params.set(paramName, allTags);
} }
@@ -153,7 +141,6 @@ export default function TagSelector(props: TagSelectorProps) {
}; };
const toggleFilterMode = () => { const toggleFilterMode = () => {
// Get current tags BEFORE changing mode
const currentSelected = selectedTags(); const currentSelected = selectedTags();
const newMode = filterMode() === "whitelist" ? "blacklist" : "whitelist"; const newMode = filterMode() === "whitelist" ? "blacklist" : "whitelist";
@@ -164,14 +151,12 @@ export default function TagSelector(props: TagSelectorProps) {
const newParamName = newMode === "whitelist" ? "include" : "filter"; const newParamName = newMode === "whitelist" ? "include" : "filter";
const oldParamName = newMode === "whitelist" ? "filter" : "include"; const oldParamName = newMode === "whitelist" ? "filter" : "include";
// Remove old param and set new one
params.delete(oldParamName); params.delete(oldParamName);
if (currentSelected.length > 0) { if (currentSelected.length > 0) {
const paramValue = currentSelected.join("|"); const paramValue = currentSelected.join("|");
params.set(newParamName, paramValue); params.set(newParamName, paramValue);
} else { } else {
// Always keep the param, even if empty
params.set(newParamName, ""); params.set(newParamName, "");
} }

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,11 @@ export const Mermaid = Node.create({
content: { content: {
default: "", default: "",
parseHTML: (element) => { parseHTML: (element) => {
// Try to get code element
const code = element.querySelector("code"); const code = element.querySelector("code");
if (code) { if (code) {
// Get text content, which strips out all HTML tags (including spans from syntax highlighting) // Get text content, which strips out all HTML tags (including spans from syntax highlighting)
return code.textContent || ""; return code.textContent || "";
} }
// Fallback to element's own text content
return element.textContent || ""; return element.textContent || "";
}, },
renderHTML: (attributes) => { renderHTML: (attributes) => {
@@ -191,15 +189,12 @@ export const Mermaid = Node.create({
} }
try { try {
// Dynamic import to avoid bundling issues
const mermaid = (await import("mermaid")).default; const mermaid = (await import("mermaid")).default;
await mermaid.parse(content); await mermaid.parse(content);
// Valid - green indicator
statusIndicator.className = statusIndicator.className =
"absolute top-2 left-2 w-3 h-3 rounded-full bg-green opacity-0 group-hover:opacity-100 transition-opacity duration-200"; "absolute top-2 left-2 w-3 h-3 rounded-full bg-green opacity-0 group-hover:opacity-100 transition-opacity duration-200";
statusIndicator.title = "Valid mermaid syntax"; statusIndicator.title = "Valid mermaid syntax";
} catch (err) { } catch (err) {
// Invalid - red indicator
statusIndicator.className = statusIndicator.className =
"absolute top-2 left-2 w-3 h-3 rounded-full bg-red opacity-0 group-hover:opacity-100 transition-opacity duration-200"; "absolute top-2 left-2 w-3 h-3 rounded-full bg-red opacity-0 group-hover:opacity-100 transition-opacity duration-200";
statusIndicator.title = `Invalid syntax: ${ statusIndicator.title = `Invalid syntax: ${
@@ -208,7 +203,6 @@ export const Mermaid = Node.create({
} }
}; };
// Run validation
validateSyntax(); validateSyntax();
// Edit button overlay - visible on mobile tap/selection, hover on desktop // Edit button overlay - visible on mobile tap/selection, hover on desktop
@@ -240,22 +234,18 @@ export const Mermaid = Node.create({
const pos = getPos(); const pos = getPos();
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
// Check if this node is selected
const nodeIsSelected = from === pos && to === pos + node.nodeSize; const nodeIsSelected = from === pos && to === pos + node.nodeSize;
if (nodeIsSelected !== isSelected) { if (nodeIsSelected !== isSelected) {
isSelected = nodeIsSelected; isSelected = nodeIsSelected;
if (isSelected) { if (isSelected) {
// Show button when selected (for mobile)
editBtn.style.opacity = "1"; editBtn.style.opacity = "1";
} else { } else {
// Hide button when not selected (reset to CSS control)
editBtn.style.opacity = ""; editBtn.style.opacity = "";
} }
} }
}; };
// Listen for selection changes
const plugin = editor.view.state.plugins.find( const plugin = editor.view.state.plugins.find(
(p: any) => p.spec?.key === "mermaidSelection" (p: any) => p.spec?.key === "mermaidSelection"
); );
@@ -266,10 +256,8 @@ export const Mermaid = Node.create({
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
// Check selection periodically when visible
updateInterval = setInterval(updateButtonVisibility, 100); updateInterval = setInterval(updateButtonVisibility, 100);
} else { } else {
// Stop checking when not visible
if (updateInterval) { if (updateInterval) {
clearInterval(updateInterval); clearInterval(updateInterval);
updateInterval = null; updateInterval = null;
@@ -282,7 +270,6 @@ export const Mermaid = Node.create({
observer.observe(dom); observer.observe(dom);
// Also check on touch
dom.addEventListener("touchstart", () => { dom.addEventListener("touchstart", () => {
setTimeout(updateButtonVisibility, 50); setTimeout(updateButtonVisibility, 50);
}); });
@@ -299,7 +286,6 @@ export const Mermaid = Node.create({
return false; return false;
} }
code.textContent = updatedNode.attrs.content || ""; code.textContent = updatedNode.attrs.content || "";
// Re-validate on update
validateSyntax(); validateSyntax();
updateButtonVisibility(); updateButtonVisibility();
return true; return true;

View File

@@ -10,9 +10,9 @@
export const AUTH_CONFIG = { export const AUTH_CONFIG = {
JWT_EXPIRY: "14d" as const, JWT_EXPIRY: "14d" as const,
JWT_EXPIRY_SHORT: "12h" as const, JWT_EXPIRY_SHORT: "12h" as const,
SESSION_COOKIE_MAX_AGE: 60 * 60 * 24 * 14, // 14 days SESSION_COOKIE_MAX_AGE: 60 * 60 * 24 * 14,
REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14, // 14 days REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14,
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, // 14 days CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const, EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const, EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
LINEAGE_JWT_EXPIRY: "14d" as const LINEAGE_JWT_EXPIRY: "14d" as const
@@ -51,7 +51,6 @@ export const PASSWORD_RESET_CONFIG = {
// ============================================================ // ============================================================
export const COOLDOWN_TIMERS = { export const COOLDOWN_TIMERS = {
// those without * 1000 are in seconds
EMAIL_LOGIN_LINK_MS: 2 * 60 * 1000, EMAIL_LOGIN_LINK_MS: 2 * 60 * 1000,
EMAIL_LOGIN_LINK_COOKIE_MAX_AGE: 2 * 60, EMAIL_LOGIN_LINK_COOKIE_MAX_AGE: 2 * 60,
PASSWORD_RESET_REQUEST_MS: 5 * 60 * 1000, PASSWORD_RESET_REQUEST_MS: 5 * 60 * 1000,
@@ -107,7 +106,6 @@ export const TYPEWRITER_CONFIG = {
// ============================================================ // ============================================================
export const COUNTDOWN_CONFIG = { export const COUNTDOWN_CONFIG = {
// these are in seconds
EMAIL_LOGIN_LINK_DURATION_S: 120, EMAIL_LOGIN_LINK_DURATION_S: 120,
PASSWORD_RESET_DURATION_S: 300, PASSWORD_RESET_DURATION_S: 300,
CONTACT_FORM_DURATION_S: 60, CONTACT_FORM_DURATION_S: 60,
@@ -189,8 +187,8 @@ export const TEXT_EDITOR_CONFIG = {
INITIAL_LOAD_FALLBACK_DELAY_MS: 500, INITIAL_LOAD_FALLBACK_DELAY_MS: 500,
INITIAL_LOAD_DELAY_MS: 1000, INITIAL_LOAD_DELAY_MS: 1000,
SPINNER_INTERVAL_MS: 50, SPINNER_INTERVAL_MS: 50,
HIGHLIGHT_FADE_DELAY_MS: 100, HIGHLIGHT_FADE_DELAY_MS: 400,
HIGHLIGHT_REMOVE_DELAY_MS: 700, HIGHLIGHT_REMOVE_DELAY_MS: 1000,
REFERENCE_UPDATE_DELAY_MS: 500, REFERENCE_UPDATE_DELAY_MS: 500,
SCROLL_TO_CHANGE_DELAY_MS: 100 SCROLL_TO_CHANGE_DELAY_MS: 100
} as const; } as const;
@@ -203,7 +201,6 @@ export const VALIDATION_CONFIG = {
MIN_PASSWORD_LENGTH: 8, MIN_PASSWORD_LENGTH: 8,
PASSWORD_REQUIRE_UPPERCASE: true, PASSWORD_REQUIRE_UPPERCASE: true,
PASSWORD_REQUIRE_NUMBER: true, PASSWORD_REQUIRE_NUMBER: true,
// changed to just recommended, may change again in future
PASSWORD_REQUIRE_SPECIAL: false, PASSWORD_REQUIRE_SPECIAL: false,
MAX_CONTACT_MESSAGE_LENGTH: 500, MAX_CONTACT_MESSAGE_LENGTH: 500,
MIN_PASSWORD_CONF_LENGTH_FOR_ERROR: 6 MIN_PASSWORD_CONF_LENGTH_FOR_ERROR: 6

View File

@@ -46,10 +46,8 @@ const getInitialTheme = () => {
}; };
export const DarkModeProvider: ParentComponent = (props) => { export const DarkModeProvider: ParentComponent = (props) => {
// Initialize with correct theme synchronously
const [isDark, setIsDark] = createSignal(getInitialTheme()); const [isDark, setIsDark] = createSignal(getInitialTheme());
// Force update immediately on client to fix hydration mismatch
onMount(() => { onMount(() => {
const actualTheme = getInitialTheme(); const actualTheme = getInitialTheme();
if (isDark() !== actualTheme) { if (isDark() !== actualTheme) {
@@ -58,9 +56,7 @@ export const DarkModeProvider: ParentComponent = (props) => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
// Listen for system theme changes
const handleChange = (e: MediaQueryListEvent) => { const handleChange = (e: MediaQueryListEvent) => {
// Only update if there's no explicit override
const storedOverride = localStorage.getItem(STORAGE_KEY); const storedOverride = localStorage.getItem(STORAGE_KEY);
if (storedOverride === null) { if (storedOverride === null) {
setIsDark(e.matches); setIsDark(e.matches);
@@ -74,7 +70,6 @@ export const DarkModeProvider: ParentComponent = (props) => {
}); });
}); });
// Reactively update DOM when isDark changes
createEffect(() => { createEffect(() => {
if (isDark()) { if (isDark()) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
@@ -89,12 +84,10 @@ export const DarkModeProvider: ParentComponent = (props) => {
const newValue = !isDark(); const newValue = !isDark();
setIsDark(newValue); setIsDark(newValue);
// Only persist if different from system preference
const systemPreference = getSystemPreference(); const systemPreference = getSystemPreference();
if (newValue !== systemPreference) { if (newValue !== systemPreference) {
localStorage.setItem(STORAGE_KEY, newValue ? "dark" : "light"); localStorage.setItem(STORAGE_KEY, newValue ? "dark" : "light");
} else { } else {
// If matching system preference, remove override
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
}; };
@@ -102,12 +95,10 @@ export const DarkModeProvider: ParentComponent = (props) => {
const setDarkMode = (dark: boolean) => { const setDarkMode = (dark: boolean) => {
setIsDark(dark); setIsDark(dark);
// Only persist if different from system preference
const systemPreference = getSystemPreference(); const systemPreference = getSystemPreference();
if (dark !== systemPreference) { if (dark !== systemPreference) {
localStorage.setItem(STORAGE_KEY, dark ? "dark" : "light"); localStorage.setItem(STORAGE_KEY, dark ? "dark" : "light");
} else { } else {
// If matching system preference, remove override
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
}; };

4
src/env/client.ts vendored
View File

@@ -1,7 +1,6 @@
import { z } from "zod"; import { z } from "zod";
const clientEnvSchema = z.object({ const clientEnvSchema = z.object({
// Client-side environment variables (VITE_ prefixed)
VITE_DOMAIN: z.string().min(1), VITE_DOMAIN: z.string().min(1),
VITE_AWS_BUCKET_STRING: z.string().min(1), VITE_AWS_BUCKET_STRING: z.string().min(1),
VITE_GOOGLE_CLIENT_ID: z.string().min(1), VITE_GOOGLE_CLIENT_ID: z.string().min(1),
@@ -11,10 +10,8 @@ const clientEnvSchema = z.object({
VITE_INFILL_ENDPOINT: z.string().min(1) VITE_INFILL_ENDPOINT: z.string().min(1)
}); });
// Type inference
export type ClientEnv = z.infer<typeof clientEnvSchema>; export type ClientEnv = z.infer<typeof clientEnvSchema>;
// Validation function for client-side with detailed error messages
export const validateClientEnv = ( export const validateClientEnv = (
envVars: Record<string, string | undefined> envVars: Record<string, string | undefined>
): ClientEnv => { ): ClientEnv => {
@@ -70,7 +67,6 @@ export const validateClientEnv = (
} }
}; };
// Validate and export environment variables directly
const validateAndExportEnv = (): ClientEnv => { const validateAndExportEnv = (): ClientEnv => {
try { try {
const validated = validateClientEnv(import.meta.env); const validated = validateClientEnv(import.meta.env);

4
src/env/server.ts vendored
View File

@@ -1,7 +1,6 @@
import { z } from "zod"; import { z } from "zod";
const serverEnvSchema = z.object({ const serverEnvSchema = z.object({
// Server-side environment variables
NODE_ENV: z.enum(["development", "test", "production"]), NODE_ENV: z.enum(["development", "test", "production"]),
ADMIN_EMAIL: z.string().min(1), ADMIN_EMAIL: z.string().min(1),
ADMIN_ID: z.string().min(1), ADMIN_ID: z.string().min(1),
@@ -35,10 +34,8 @@ const serverEnvSchema = z.object({
INFILL_BEARER_TOKEN: z.string().min(1) INFILL_BEARER_TOKEN: z.string().min(1)
}); });
// Type inference
export type ServerEnv = z.infer<typeof serverEnvSchema>; export type ServerEnv = z.infer<typeof serverEnvSchema>;
// Validation function for server-side with detailed error messages
export const validateServerEnv = ( export const validateServerEnv = (
envVars: Record<string, string | undefined> envVars: Record<string, string | undefined>
): ServerEnv => { ): ServerEnv => {
@@ -91,7 +88,6 @@ export const validateServerEnv = (
} }
}; };
// Validate and export environment variables directly
const validateAndExportEnv = (): ServerEnv => { const validateAndExportEnv = (): ServerEnv => {
try { try {
const validated = validateServerEnv(process.env); const validated = validateServerEnv(process.env);

View File

@@ -2,7 +2,6 @@ import { createTRPCProxyClient, httpBatchLink, loggerLink } from "@trpc/client";
import type { AppRouter } from "~/server/api/root"; import type { AppRouter } from "~/server/api/root";
const getBaseUrl = () => { const getBaseUrl = () => {
// Browser: use relative URL
if (typeof window !== "undefined") return ""; if (typeof window !== "undefined") return "";
const domain = import.meta.env.VITE_DOMAIN; const domain = import.meta.env.VITE_DOMAIN;
@@ -27,12 +26,10 @@ function getCSRFToken(): string | undefined {
export const api = createTRPCProxyClient<AppRouter>({ export const api = createTRPCProxyClient<AppRouter>({
links: [ links: [
// Only enable logging in development mode
...(process.env.NODE_ENV === "development" ...(process.env.NODE_ENV === "development"
? [ ? [
loggerLink({ loggerLink({
enabled: (opts) => { enabled: (opts) => {
// Suppress 401 UNAUTHORIZED errors from logs
const is401 = const is401 =
opts.direction === "down" && opts.direction === "down" &&
opts.result instanceof Error && opts.result instanceof Error &&
@@ -42,7 +39,6 @@ export const api = createTRPCProxyClient<AppRouter>({
}) })
] ]
: []), : []),
// identifies what url will handle trpc requests
httpBatchLink({ httpBatchLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
headers: () => { headers: () => {

View File

@@ -4,11 +4,7 @@
*/ */
/** /**
* Safe fetch wrapper that suppresses console errors for expected 401 responses * Fetch wrapper for auth checks where 401s are expected and should not trigger console errors
* Use this instead of direct fetch() calls when 401s are expected (e.g., auth checks)
* @param input - URL or Request object
* @param init - Fetch options
* @returns Promise<Response>
*/ */
export async function safeFetch( export async function safeFetch(
input: RequestInfo | URL, input: RequestInfo | URL,
@@ -18,17 +14,12 @@ export async function safeFetch(
const response = await fetch(input, init); const response = await fetch(input, init);
return response; return response;
} catch (error) { } catch (error) {
// Re-throw the error - this is for actual network failures
throw error; throw error;
} }
} }
/** /**
* Inserts soft hyphens (&shy;) into long words to enable manual hyphenation * Inserts soft hyphens (&shy;) for manual hyphenation. Uses actual characters for Typewriter compatibility.
* Works with Typewriter component since it uses actual characters
* @param text - The text to add hyphens to
* @param minWordLength - Minimum word length to hyphenate (default 8)
* @returns Text with soft hyphens inserted
*/ */
export function insertSoftHyphens( export function insertSoftHyphens(
text: string, text: string,
@@ -37,26 +28,20 @@ export function insertSoftHyphens(
return text return text
.split(" ") .split(" ")
.map((word) => { .map((word) => {
// Skip short words
if (word.length < minWordLength) return word; if (word.length < minWordLength) return word;
// Common English hyphenation patterns
const patterns = [ const patterns = [
// Prefixes (break after)
{ {
pattern: pattern:
/^(un|re|in|dis|en|non|pre|pro|anti|de|mis|over|sub|super|trans|under)(.+)/i, /^(un|re|in|dis|en|non|pre|pro|anti|de|mis|over|sub|super|trans|under)(.+)/i,
split: 1 split: 1
}, },
// Suffixes (break before)
{ {
pattern: pattern:
/(.+)(ing|tion|sion|ness|ment|able|ible|ful|less|ship|hood|ward|like)$/i, /(.+)(ing|tion|sion|ness|ment|able|ible|ful|less|ship|hood|ward|like)$/i,
split: 1 split: 1
}, },
// Double consonants (break between)
{ pattern: /(.+[aeiou])([bcdfghjklmnpqrstvwxyz])\2(.+)/i, split: 2 }, { pattern: /(.+[aeiou])([bcdfghjklmnpqrstvwxyz])\2(.+)/i, split: 2 },
// Compound words with common parts
{ {
pattern: pattern:
/(.+)(stand|work|time|place|where|thing|back|over|under|out)$/i, /(.+)(stand|work|time|place|where|thing|back|over|under|out)$/i,
@@ -68,16 +53,13 @@ export function insertSoftHyphens(
const match = word.match(pattern); const match = word.match(pattern);
if (match) { if (match) {
if (split === 1) { if (split === 1) {
// Break after first capture group
return match[1] + "\u00AD" + match[2]; return match[1] + "\u00AD" + match[2];
} else if (split === 2) { } else if (split === 2) {
// Break between doubled consonants
return match[1] + match[2] + "\u00AD" + match[2] + match[3]; return match[1] + match[2] + "\u00AD" + match[2] + match[3];
} }
} }
} }
// Fallback: Insert soft hyphen every 6-8 characters in very long words
if (word.length > 12) { if (word.length > 12) {
const chunks: string[] = []; const chunks: string[] = [];
for (let i = 0; i < word.length; i += 6) { for (let i = 0; i < word.length; i += 6) {

View File

@@ -1,24 +1,8 @@
/**
* Comment System Utility Functions
*
* Shared utility functions for:
* - Comment sorting algorithms
* - Comment filtering and tree building
* - Debouncing
*/
import type { Comment, CommentReaction, SortingMode } from "~/types/comment"; import type { Comment, CommentReaction, SortingMode } from "~/types/comment";
import { getSQLFormattedDate } from "./date-utils"; import { getSQLFormattedDate } from "./date-utils";
export { getSQLFormattedDate }; export { getSQLFormattedDate };
// ============================================================================
// Comment Tree Utilities
// ============================================================================
/**
* Gets all child comments for a given parent comment ID
*/
export function getChildComments( export function getChildComments(
parentCommentID: number, parentCommentID: number,
allComments: Comment[] | undefined allComments: Comment[] | undefined
@@ -30,9 +14,6 @@ export function getChildComments(
); );
} }
/**
* Counts the total number of comments including all nested children
*/
export function getTotalCommentCount( export function getTotalCommentCount(
topLevelComments: Comment[], topLevelComments: Comment[],
allComments: Comment[] allComments: Comment[]
@@ -40,10 +21,6 @@ export function getTotalCommentCount(
return allComments.length; return allComments.length;
} }
/**
* Gets the nesting level of a comment in the tree
* Top-level comments (parent_comment_id = -1 or null) are level 0
*/
export function getCommentLevel( export function getCommentLevel(
comment: Comment, comment: Comment,
allComments: Comment[] allComments: Comment[]
@@ -66,17 +43,9 @@ export function getCommentLevel(
return level; return level;
} }
// ============================================================================
// Comment Sorting Algorithms
// ============================================================================
/** /**
* @deprecated Server-side sorting is now implemented in the blog post route. * @deprecated Server-side SQL sorting preferred for performance
* Comments are sorted by SQL queries for better performance. * Logarithmic decay formula: score / log(age + 2)
* This function remains for backward compatibility only.
*
* Calculates "hot" score for a comment based on votes and time
* Uses logarithmic decay for older comments
*/ */
function calculateHotScore( function calculateHotScore(
upvotes: number, upvotes: number,
@@ -88,17 +57,11 @@ function calculateHotScore(
const commentTime = new Date(date).getTime(); const commentTime = new Date(date).getTime();
const ageInHours = (now - commentTime) / (1000 * 60 * 60); const ageInHours = (now - commentTime) / (1000 * 60 * 60);
// Logarithmic decay: score / log(age + 2)
// Adding 2 prevents division by zero for very new comments
return score / Math.log10(ageInHours + 2); return score / Math.log10(ageInHours + 2);
} }
/** /**
* @deprecated Server-side sorting is now implemented in the blog post route. * @deprecated Server-side SQL sorting preferred for performance
* Use SQL-based sorting instead for better performance.
* This function remains for backward compatibility only.
*
* Counts upvotes for a comment from reaction map
*/ */
function getUpvoteCount( function getUpvoteCount(
commentID: number, commentID: number,
@@ -111,11 +74,7 @@ function getUpvoteCount(
} }
/** /**
* @deprecated Server-side sorting is now implemented in the blog post route. * @deprecated Server-side SQL sorting preferred for performance
* Use SQL-based sorting instead for better performance.
* This function remains for backward compatibility only.
*
* Counts downvotes for a comment from reaction map
*/ */
function getDownvoteCount( function getDownvoteCount(
commentID: number, commentID: number,
@@ -128,18 +87,7 @@ function getDownvoteCount(
} }
/** /**
* @deprecated Server-side sorting is now implemented in the blog post route. * @deprecated Use server-side SQL sorting in routes/blog/[title]/index.tsx for better performance
* Comments are now sorted by SQL queries in src/routes/blog/[title]/index.tsx
* for better performance and reduced client-side processing.
* This function remains for backward compatibility only.
*
* Sorts comments based on the selected sorting mode
*
* Modes:
* - newest: Most recent first
* - oldest: Oldest first
* - highest_rated: Most upvotes minus downvotes
* - hot: Combines votes and recency (Reddit-style)
*/ */
export function sortComments( export function sortComments(
comments: Comment[], comments: Comment[],
@@ -190,20 +138,10 @@ export function sortComments(
} }
} }
// ============================================================================
// Validation Utilities
// ============================================================================
/**
* Validates that a comment body meets requirements
*/
export function isValidCommentBody(body: string): boolean { export function isValidCommentBody(body: string): boolean {
return body.trim().length > 0 && body.length <= 10000; return body.trim().length > 0 && body.length <= 10000;
} }
/**
* Checks if a user can modify (edit/delete) a comment
*/
export function canModifyComment( export function canModifyComment(
userID: string, userID: string,
commenterID: string, commenterID: string,
@@ -214,9 +152,6 @@ export function canModifyComment(
return userID === commenterID; return userID === commenterID;
} }
/**
* Checks if a user can delete with database-level deletion
*/
export function canDatabaseDelete( export function canDatabaseDelete(
privilegeLevel: "admin" | "user" | "anonymous" privilegeLevel: "admin" | "user" | "anonymous"
): boolean { ): boolean {

View File

@@ -12,7 +12,6 @@ export function createWindowWidth(debounceMs?: number): Accessor<number> {
const [width, setWidth] = createSignal(initialWidth); const [width, setWidth] = createSignal(initialWidth);
onMount(() => { onMount(() => {
// Sync to actual client width immediately on mount to avoid hydration mismatch
setWidth(window.innerWidth); setWidth(window.innerWidth);
let timeoutId: ReturnType<typeof setTimeout> | undefined; let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -86,7 +85,6 @@ export async function resizeImage(
img.onload = () => { img.onload = () => {
let { width, height } = img; let { width, height } = img;
// Calculate new dimensions maintaining aspect ratio
if (width > maxWidth || height > maxHeight) { if (width > maxWidth || height > maxHeight) {
const aspectRatio = width / height; const aspectRatio = width / height;
@@ -102,10 +100,8 @@ export async function resizeImage(
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
// Draw image on canvas with new dimensions
ctx.drawImage(img, 0, 0, width, height); ctx.drawImage(img, 0, 0, width, height);
// Convert canvas to blob
canvas.toBlob( canvas.toBlob(
(blob) => { (blob) => {
if (blob) { if (blob) {
@@ -123,7 +119,6 @@ export async function resizeImage(
reject(new Error("Failed to load image")); reject(new Error("Failed to load image"));
}; };
// Load image from file
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (e.target?.result) { if (e.target?.result) {

View File

@@ -14,7 +14,6 @@ export default async function AddImageToS3(
try { try {
const filename = (file as File).name; const filename = (file as File).name;
// Get pre-signed URL from tRPC endpoint
const { uploadURL, key } = await api.misc.getPreSignedURL.mutate({ const { uploadURL, key } = await api.misc.getPreSignedURL.mutate({
type, type,
title, title,
@@ -23,11 +22,9 @@ export default async function AddImageToS3(
console.log("url: " + uploadURL, "key: " + key); console.log("url: " + uploadURL, "key: " + key);
// Extract content type from filename extension
const ext = /^.+\.([^.]+)$/.exec(filename); const ext = /^.+\.([^.]+)$/.exec(filename);
const contentType = ext ? `image/${ext[1]}` : "application/octet-stream"; const contentType = ext ? `image/${ext[1]}` : "application/octet-stream";
// Upload original file to S3 using pre-signed URL
const uploadResponse = await fetch(uploadURL, { const uploadResponse = await fetch(uploadURL, {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -40,19 +37,12 @@ export default async function AddImageToS3(
throw new Error("Failed to upload file to S3"); throw new Error("Failed to upload file to S3");
} }
// For blog cover images, also create and upload a thumbnail
if (type === "blog") { if (type === "blog") {
try { try {
// Create thumbnail (max 200x200px for sidebar display)
const thumbnail = await resizeImage(file, 200, 200, 0.8); const thumbnail = await resizeImage(file, 200, 200, 0.8);
// Generate thumbnail filename: insert "-small" before extension const thumbnailFilename = filename.replace(/(\.[^.]+)$/, "-small$1");
const thumbnailFilename = filename.replace(
/(\.[^.]+)$/,
"-small$1"
);
// Get pre-signed URL for thumbnail
const { uploadURL: thumbnailUploadURL } = const { uploadURL: thumbnailUploadURL } =
await api.misc.getPreSignedURL.mutate({ await api.misc.getPreSignedURL.mutate({
type, type,
@@ -60,24 +50,21 @@ export default async function AddImageToS3(
filename: thumbnailFilename filename: thumbnailFilename
}); });
// Upload thumbnail to S3
const thumbnailUploadResponse = await fetch(thumbnailUploadURL, { const thumbnailUploadResponse = await fetch(thumbnailUploadURL, {
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "image/jpeg" // Thumbnails are always JPEG "Content-Type": "image/jpeg"
}, },
body: thumbnail body: thumbnail
}); });
if (!thumbnailUploadResponse.ok) { if (!thumbnailUploadResponse.ok) {
console.error("Failed to upload thumbnail to S3"); console.error("Failed to upload thumbnail to S3");
// Don't fail the entire upload if thumbnail fails
} else { } else {
console.log("Thumbnail uploaded successfully"); console.log("Thumbnail uploaded successfully");
} }
} catch (thumbnailError) { } catch (thumbnailError) {
console.error("Thumbnail creation/upload failed:", thumbnailError); console.error("Thumbnail creation/upload failed:", thumbnailError);
// Don't fail the entire upload if thumbnail fails
} }
} }

View File

@@ -18,7 +18,6 @@ export interface CommandContext {
} }
export const createTerminalCommands = (context: CommandContext) => { export const createTerminalCommands = (context: CommandContext) => {
// Define available routes
const routes = [ const routes = [
{ path: "/", name: "home" }, { path: "/", name: "home" },
{ path: "/blog", name: "blog" }, { path: "/blog", name: "blog" },
@@ -99,7 +98,6 @@ export const createTerminalCommands = (context: CommandContext) => {
clear: { clear: {
action: () => { action: () => {
context.addToHistory("clear", "", "info"); context.addToHistory("clear", "", "info");
// Clear will be handled by the component
}, },
description: "Clear terminal history" description: "Clear terminal history"
}, },
@@ -231,7 +229,6 @@ export const createTerminalCommands = (context: CommandContext) => {
} }
}; };
// Add all cd variants for each route
routes.forEach((route) => { routes.forEach((route) => {
commands[`cd ${route.name}`] = { commands[`cd ${route.name}`] = {
action: () => context.navigate(route.path), action: () => context.navigate(route.path),

View File

@@ -8,14 +8,11 @@ import { VALIDATION_CONFIG } from "~/config";
* Validate email format * Validate email format
*/ */
export function isValidEmail(email: string): boolean { export function isValidEmail(email: string): boolean {
// Basic email format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) { if (!emailRegex.test(email)) {
return false; return false;
} }
// Additional checks for invalid patterns
// Reject consecutive dots
if (email.includes("..")) { if (email.includes("..")) {
return false; return false;
} }
@@ -39,24 +36,20 @@ export function validatePassword(password: string): {
const errors: string[] = []; const errors: string[] = [];
let includesSpecial = false; let includesSpecial = false;
// Minimum length from config
if (password.length < VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) { if (password.length < VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) {
errors.push( errors.push(
`Password must be at least ${VALIDATION_CONFIG.MIN_PASSWORD_LENGTH} characters long` `Password must be at least ${VALIDATION_CONFIG.MIN_PASSWORD_LENGTH} characters long`
); );
} }
// Require uppercase letter (if configured)
if (VALIDATION_CONFIG.PASSWORD_REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) { if (VALIDATION_CONFIG.PASSWORD_REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) {
errors.push("Password must contain at least one uppercase letter"); errors.push("Password must contain at least one uppercase letter");
} }
// Require lowercase letter (always required for balanced security)
if (!/[a-z]/.test(password)) { if (!/[a-z]/.test(password)) {
errors.push("Password must contain at least one lowercase letter"); errors.push("Password must contain at least one lowercase letter");
} }
// Require number (if configured)
if (VALIDATION_CONFIG.PASSWORD_REQUIRE_NUMBER && !/[0-9]/.test(password)) { if (VALIDATION_CONFIG.PASSWORD_REQUIRE_NUMBER && !/[0-9]/.test(password)) {
errors.push("Password must contain at least one number"); errors.push("Password must contain at least one number");
} }
@@ -64,12 +57,11 @@ export function validatePassword(password: string): {
if (/[^A-Za-z0-9]/.test(password)) { if (/[^A-Za-z0-9]/.test(password)) {
includesSpecial = true; includesSpecial = true;
} }
// Require special character (if configured)
if (VALIDATION_CONFIG.PASSWORD_REQUIRE_SPECIAL && !includesSpecial) { if (VALIDATION_CONFIG.PASSWORD_REQUIRE_SPECIAL && !includesSpecial) {
errors.push("Password must contain at least one special character"); errors.push("Password must contain at least one special character");
} }
// Check for common weak passwords
const commonPasswords = [ const commonPasswords = [
"password", "password",
"1234", "1234",
@@ -94,7 +86,6 @@ export function validatePassword(password: string): {
} }
} }
// Calculate password strength
let strength: PasswordStrength = "weak"; let strength: PasswordStrength = "weak";
if (errors.length === 0) { if (errors.length === 0) {

View File

@@ -123,7 +123,6 @@ export default function AccountPage() {
} }
}); });
// Profile image handlers
const handleImageDrop = (acceptedFiles: File[]) => { const handleImageDrop = (acceptedFiles: File[]) => {
acceptedFiles.forEach((file: File) => { acceptedFiles.forEach((file: File) => {
setProfileImage(file); setProfileImage(file);
@@ -184,7 +183,6 @@ export default function AccountPage() {
setProfileImageStateChange(false); setProfileImageStateChange(false);
setTimeout(() => setShowImageSuccess(false), 3000); setTimeout(() => setShowImageSuccess(false), 3000);
// Update preSetHolder with new image
setPreSetHolder(imageUrl || null); setPreSetHolder(imageUrl || null);
} else { } else {
alert("Error updating profile image!"); alert("Error updating profile image!");
@@ -197,7 +195,6 @@ export default function AccountPage() {
} }
}; };
// Email update handler
const setEmailTrigger = async (e: Event) => { const setEmailTrigger = async (e: Event) => {
e.preventDefault(); e.preventDefault();
if (!emailRef) return; if (!emailRef) return;
@@ -231,7 +228,6 @@ export default function AccountPage() {
} }
}; };
// Display name update handler
const setDisplayNameTrigger = async (e: Event) => { const setDisplayNameTrigger = async (e: Event) => {
e.preventDefault(); e.preventDefault();
if (!displayNameRef) return; if (!displayNameRef) return;
@@ -260,14 +256,12 @@ export default function AccountPage() {
} }
}; };
// Password change/set handler
const handlePasswordSubmit = async (e: Event) => { const handlePasswordSubmit = async (e: Event) => {
e.preventDefault(); e.preventDefault();
const userProfile = currentUser(); const userProfile = currentUser();
if (!userProfile) return; if (!userProfile) return;
if (userProfile.hasPassword) { if (userProfile.hasPassword) {
// Change password (requires old password)
if (!oldPasswordRef || !newPasswordRef || !newPasswordConfRef) return; if (!oldPasswordRef || !newPasswordRef || !newPasswordConfRef) return;
const oldPassword = oldPasswordRef.value; const oldPassword = oldPasswordRef.value;
@@ -316,7 +310,6 @@ export default function AccountPage() {
setPasswordChangeLoading(false); setPasswordChangeLoading(false);
} }
} else { } else {
// Set password (first time for OAuth users)
if (!newPasswordRef || !newPasswordConfRef) return; if (!newPasswordRef || !newPasswordConfRef) return;
const newPassword = newPasswordRef.value; const newPassword = newPasswordRef.value;
@@ -348,7 +341,6 @@ export default function AccountPage() {
const result = await response.json(); const result = await response.json();
if (response.ok && result.result?.data?.success) { if (response.ok && result.result?.data?.success) {
// Refresh user data to show hasPassword = true
const profileResponse = await fetch("/api/trpc/user.getProfile"); const profileResponse = await fetch("/api/trpc/user.getProfile");
const profileResult = await profileResponse.json(); const profileResult = await profileResponse.json();
if (profileResult.result?.data) { if (profileResult.result?.data) {
@@ -370,7 +362,6 @@ export default function AccountPage() {
} }
}; };
// Delete account handler
const deleteAccountTrigger = async (e: Event) => { const deleteAccountTrigger = async (e: Event) => {
e.preventDefault(); e.preventDefault();
if (!deleteAccountPasswordRef) return; if (!deleteAccountPasswordRef) return;
@@ -400,7 +391,6 @@ export default function AccountPage() {
} }
}; };
// Resend email verification
const sendEmailVerification = async () => { const sendEmailVerification = async () => {
const userProfile = currentUser(); const userProfile = currentUser();
if (!userProfile?.email) return; if (!userProfile?.email) return;
@@ -460,7 +450,6 @@ export default function AccountPage() {
setPasswordBlurred(true); setPasswordBlurred(true);
}; };
// Sign out handler
const handleSignOut = async () => { const handleSignOut = async () => {
setSignOutLoading(true); setSignOutLoading(true);
try { try {
@@ -472,7 +461,6 @@ export default function AccountPage() {
} }
}; };
// Helper to get provider display name
const getProviderName = (provider: UserProfile["provider"]) => { const getProviderName = (provider: UserProfile["provider"]) => {
switch (provider) { switch (provider) {
case "google": case "google":
@@ -486,7 +474,6 @@ export default function AccountPage() {
} }
}; };
// Helper to get provider icon color
const getProviderColor = (provider: UserProfile["provider"]) => { const getProviderColor = (provider: UserProfile["provider"]) => {
switch (provider) { switch (provider) {
case "google": case "google":

View File

@@ -22,11 +22,9 @@ export async function GET(event: APIEvent) {
} }
try { try {
// Create tRPC caller to invoke the githubCallback procedure
const ctx = await createTRPCContext(event); const ctx = await createTRPCContext(event);
const caller = appRouter.createCaller(ctx); const caller = appRouter.createCaller(ctx);
// Call the GitHub callback handler
const result = await caller.auth.githubCallback({ code }); const result = await caller.auth.githubCallback({ code });
if (result.success) { if (result.success) {

View File

@@ -22,11 +22,9 @@ export async function GET(event: APIEvent) {
} }
try { try {
// Create tRPC caller to invoke the googleCallback procedure
const ctx = await createTRPCContext(event); const ctx = await createTRPCContext(event);
const caller = appRouter.createCaller(ctx); const caller = appRouter.createCaller(ctx);
// Call the Google callback handler
const result = await caller.auth.googleCallback({ code }); const result = await caller.auth.googleCallback({ code });
if (result.success) { if (result.success) {

View File

@@ -5,14 +5,13 @@ export async function POST() {
"use server"; "use server";
const event = getEvent()!; const event = getEvent()!;
// Clear the userIDToken cookie (the actual session cookie)
setCookie(event, "userIDToken", "", { setCookie(event, "userIDToken", "", {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: true, // Always enforce secure cookies secure: true,
sameSite: "lax", sameSite: "lax",
maxAge: 0, // Expire immediately maxAge: 0,
expires: new Date(0) // Set expiry to past date expires: new Date(0)
}); });
return new Response(null, { return new Response(null, {

View File

@@ -4,16 +4,11 @@ import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils"; import { createTRPCContext } from "~/server/api/utils";
const handler = (event: APIEvent) => { const handler = (event: APIEvent) => {
// adapts tRPC to fetch API style requests
return fetchRequestHandler({ return fetchRequestHandler({
// the endpoint handling the requests
endpoint: "/api/trpc", endpoint: "/api/trpc",
// the request object
req: event.request, req: event.request,
// the router for handling the requests
router: appRouter, router: appRouter,
// any arbitrary data that should be available to all actions createContext: () => createTRPCContext(event)
createContext: () => createTRPCContext(event),
}); });
}; };

View File

@@ -37,7 +37,6 @@ const getPostByTitle = query(
const userID = await getUserID(event.nativeEvent); const userID = await getUserID(event.nativeEvent);
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Handle by-id route: lookup post by ID and redirect to title-based URL
if (title === "by-id") { if (title === "by-id") {
const url = new URL(event.request.url); const url = new URL(event.request.url);
const id = url.searchParams.get("id"); const id = url.searchParams.get("id");
@@ -128,7 +127,6 @@ const getPostByTitle = query(
}; };
} }
// Build conditional evaluation context
const conditionalContext = { const conditionalContext = {
isAuthenticated: userID !== null, isAuthenticated: userID !== null,
privilegeLevel: privilegeLevel, privilegeLevel: privilegeLevel,
@@ -143,14 +141,11 @@ const getPostByTitle = query(
post.body = parseConditionals(post.body, conditionalContext); post.body = parseConditionals(post.body, conditionalContext);
} catch (error) { } catch (error) {
console.error("Error parsing conditionals in post body:", error); console.error("Error parsing conditionals in post body:", error);
// Fall back to showing original content
} }
} }
// Fetch comments with sorting
let commentQuery = "SELECT * FROM Comment WHERE post_id = ?"; let commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
// Build ORDER BY clause based on sortBy parameter
switch (sortBy) { switch (sortBy) {
case "newest": case "newest":
commentQuery += " ORDER BY date DESC"; commentQuery += " ORDER BY date DESC";
@@ -159,7 +154,6 @@ const getPostByTitle = query(
commentQuery += " ORDER BY date ASC"; commentQuery += " ORDER BY date ASC";
break; break;
case "highest_rated": case "highest_rated":
// Calculate net score (upvotes - downvotes) for each comment
commentQuery = ` commentQuery = `
SELECT c.*, SELECT c.*,
COALESCE(( COALESCE((
@@ -177,7 +171,6 @@ const getPostByTitle = query(
`; `;
break; break;
case "hot": case "hot":
// Calculate hot score: (upvotes - downvotes) / log10(age_in_hours + 2)
commentQuery = ` commentQuery = `
SELECT c.*, SELECT c.*,
(COALESCE(( (COALESCE((
@@ -201,16 +194,13 @@ const getPostByTitle = query(
await conn.execute({ sql: commentQuery, args: [post.id] }) await conn.execute({ sql: commentQuery, args: [post.id] })
).rows; ).rows;
// Fetch likes
const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?"; const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?";
const likes = (await conn.execute({ sql: likeQuery, args: [post.id] })) const likes = (await conn.execute({ sql: likeQuery, args: [post.id] }))
.rows; .rows;
// Fetch tags
const tagQuery = "SELECT * FROM Tag WHERE post_id = ?"; const tagQuery = "SELECT * FROM Tag WHERE post_id = ?";
const tags = (await conn.execute({ sql: tagQuery, args: [post.id] })).rows; const tags = (await conn.execute({ sql: tagQuery, args: [post.id] })).rows;
// Build commenter map
const commenterToCommentIDMap = new Map<string, number[]>(); const commenterToCommentIDMap = new Map<string, number[]>();
comments.forEach((comment: any) => { comments.forEach((comment: any) => {
const prev = commenterToCommentIDMap.get(comment.commenter_id) || []; const prev = commenterToCommentIDMap.get(comment.commenter_id) || [];
@@ -220,7 +210,6 @@ const getPostByTitle = query(
const commenterQuery = const commenterQuery =
"SELECT email, display_name, image FROM User WHERE id = ?"; "SELECT email, display_name, image FROM User WHERE id = ?";
// Convert to serializable array format
const userCommentArray: Array<[UserPublicData, number[]]> = []; const userCommentArray: Array<[UserPublicData, number[]]> = [];
for (const [key, value] of commenterToCommentIDMap.entries()) { for (const [key, value] of commenterToCommentIDMap.entries()) {
@@ -231,7 +220,6 @@ const getPostByTitle = query(
} }
} }
// Get reaction map as serializable array
const reactionArray: Array<[number, CommentReaction[]]> = []; const reactionArray: Array<[number, CommentReaction[]]> = [];
for (const comment of comments) { for (const comment of comments) {
const reactionQuery = const reactionQuery =
@@ -243,7 +231,6 @@ const getPostByTitle = query(
reactionArray.push([(comment as any).id, res.rows as CommentReaction[]]); reactionArray.push([(comment as any).id, res.rows as CommentReaction[]]);
} }
// Filter top-level comments (preserve sort order from SQL)
const topLevelComments = comments.filter( const topLevelComments = comments.filter(
(c: any) => c.parent_comment_id == null (c: any) => c.parent_comment_id == null
); );
@@ -283,7 +270,6 @@ export default function PostPage() {
{ deferStream: true } { deferStream: true }
); );
// Increment read count when post loads
createEffect(() => { createEffect(() => {
const postData = data(); const postData = data();
if (postData?.post?.id) { if (postData?.post?.id) {
@@ -310,7 +296,6 @@ export default function PostPage() {
} }
> >
{(loadedData) => { {(loadedData) => {
// Handle redirect for by-id route
if ("redirect" in loadedData()) { if ("redirect" in loadedData()) {
return <Navigate href={(loadedData() as any).redirect} />; return <Navigate href={(loadedData() as any).redirect} />;
} }
@@ -323,7 +308,6 @@ export default function PostPage() {
{(p) => { {(p) => {
const postData = loadedData(); const postData = loadedData();
// Convert arrays back to Maps for component
const userCommentMap = new Map<UserPublicData, number[]>( const userCommentMap = new Map<UserPublicData, number[]>(
postData.userCommentArray || [] postData.userCommentArray || []
); );
@@ -345,7 +329,6 @@ export default function PostPage() {
/> />
<div class="blog-overide relative -mt-16 overflow-x-hidden"> <div class="blog-overide relative -mt-16 overflow-x-hidden">
{/* Fixed banner image background */}
<div class="fixed inset-0 top-0 left-0 z-0 aspect-auto max-h-3/4 w-full overflow-hidden brightness-75 md:ml-62.5 md:max-h-[50vh] md:w-[calc(100vw-500px)]"> <div class="fixed inset-0 top-0 left-0 z-0 aspect-auto max-h-3/4 w-full overflow-hidden brightness-75 md:ml-62.5 md:max-h-[50vh] md:w-[calc(100vw-500px)]">
<img <img
src={p().banner_photo || "/blueprint.jpg"} src={p().banner_photo || "/blueprint.jpg"}
@@ -368,7 +351,6 @@ export default function PostPage() {
</div> </div>
<div class="z-10 pt-80 backdrop-blur-[0.01px] sm:pt-96 md:pt-[50vh]"> <div class="z-10 pt-80 backdrop-blur-[0.01px] sm:pt-96 md:pt-[50vh]">
{/* Content that slides over the fixed image */}
<div class="bg-base relative pb-24"> <div class="bg-base relative pb-24">
<div class="flex w-full flex-col justify-center pt-8 lg:flex-row lg:items-start lg:justify-between"> <div class="flex w-full flex-col justify-center pt-8 lg:flex-row lg:items-start lg:justify-between">
<div class="flex flex-col gap-2 px-4 md:px-8"> <div class="flex flex-col gap-2 px-4 md:px-8">
@@ -465,24 +447,12 @@ export default function PostPage() {
</div> </div>
</Show> </Show>
</div> </div>
{/* Post body */}
<PostBodyClient <PostBodyClient
body={p().body} body={p().body}
hasCodeBlock={hasCodeBlock(p().body)} hasCodeBlock={hasCodeBlock(p().body)}
/> />
<Show when={postData.privilegeLevel === "admin"}>
<div class="flex justify-center">
<A
class="border-blue bg-blue z-10 h-fit rounded border px-4 py-2 text-base shadow-md transition-all duration-300 ease-in-out hover:brightness-125 active:scale-90"
href={`/blog/edit/${p().id}`}
>
Edit
</A>
</div>
</Show>
{/* Comments section */}
<div <div
id="comments" id="comments"
class="mx-4 pt-12 pb-12 md:mx-8 lg:mx-12" class="mx-4 pt-12 pb-12 md:mx-8 lg:mx-12"

View File

@@ -24,7 +24,6 @@ const getPosts = query(async () => {
async () => { async () => {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Fetch all posts with aggregated data
let postsQuery = ` let postsQuery = `
SELECT SELECT
p.id, p.id,

View File

@@ -80,7 +80,6 @@ const sendContactEmail = action(async (formData: FormData) => {
} }
} }
// Send email
const apiKey = env.SENDINBLUE_KEY; const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
@@ -117,7 +116,6 @@ const sendContactEmail = action(async (formData: FormData) => {
} }
); );
// Set cooldown cookie
const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS); const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
setCookie("contactRequestSent", exp.toUTCString(), { setCookie("contactRequestSent", exp.toUTCString(), {
expires: exp, expires: exp,
@@ -186,7 +184,6 @@ export default function ContactPage() {
onMount(() => { onMount(() => {
setJsEnabled(true); setJsEnabled(true);
// Initialize countdown from server data
const serverData = contactData(); const serverData = contactData();
if (serverData?.remainingTime) { if (serverData?.remainingTime) {
setCountDown(serverData.remainingTime); setCountDown(serverData.remainingTime);
@@ -197,7 +194,6 @@ export default function ContactPage() {
timerIdRef = setInterval(() => calcRemainder(timer), 1000); timerIdRef = setInterval(() => calcRemainder(timer), 1000);
} }
// Fetch user data if authenticated
api.user.getProfile api.user.getProfile
.query() .query()
.then((userData) => { .then((userData) => {
@@ -205,11 +201,8 @@ export default function ContactPage() {
setUser(userData); setUser(userData);
} }
}) })
.catch(() => { .catch(() => {});
// User not authenticated, no problem
});
// Clear URL params after reading them (for better UX on refresh)
if (searchParams.success || searchParams.error) { if (searchParams.success || searchParams.error) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
const newUrl = const newUrl =
@@ -230,9 +223,7 @@ export default function ContactPage() {
}); });
}); });
// Progressive enhancement: JS-enhanced form submission
const sendEmailTrigger = async (e: Event) => { const sendEmailTrigger = async (e: Event) => {
// Only intercept if JS is enabled
if (!jsEnabled()) return; if (!jsEnabled()) return;
e.preventDefault(); e.preventDefault();

View File

@@ -3,7 +3,6 @@ import { createSignal, onMount } from "solid-js";
export default function ErrorTest() { export default function ErrorTest() {
const [shouldCrash, setShouldCrash] = createSignal(false); const [shouldCrash, setShouldCrash] = createSignal(false);
// Crash on mount if flag is set
if (shouldCrash()) { if (shouldCrash()) {
throw new Error("Test error - Error boundary triggered!"); throw new Error("Test error - Error boundary triggered!");
} }

View File

@@ -40,7 +40,6 @@ export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Derive state directly from URL parameters (no signals needed)
const register = () => searchParams.mode === "register"; const register = () => searchParams.mode === "register";
const usePassword = () => searchParams.auth === "password"; const usePassword = () => searchParams.auth === "password";
@@ -62,7 +61,6 @@ export default function LoginPage() {
let rememberMeRef: HTMLInputElement | undefined; let rememberMeRef: HTMLInputElement | undefined;
let timerInterval: number | undefined; let timerInterval: number | undefined;
// Environment variables
const googleClientId = env.VITE_GOOGLE_CLIENT_ID; const googleClientId = env.VITE_GOOGLE_CLIENT_ID;
const githubClientId = env.VITE_GITHUB_CLIENT_ID; const githubClientId = env.VITE_GITHUB_CLIENT_ID;
const domain = env.VITE_DOMAIN || "https://www.freno.me"; const domain = env.VITE_DOMAIN || "https://www.freno.me";
@@ -124,7 +122,6 @@ export default function LoginPage() {
try { try {
if (register()) { if (register()) {
// Registration flow
if (!emailRef || !passwordRef || !passwordConfRef) { if (!emailRef || !passwordRef || !passwordConfRef) {
setError("Please fill in all fields"); setError("Please fill in all fields");
setLoading(false); setLoading(false);
@@ -154,7 +151,6 @@ export default function LoginPage() {
return; return;
} }
// Call registration endpoint
const response = await fetch("/api/trpc/auth.emailRegistration", { const response = await fetch("/api/trpc/auth.emailRegistration", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -176,15 +172,12 @@ export default function LoginPage() {
"Registration failed"; "Registration failed";
const errorCode = result.error?.data?.code; const errorCode = result.error?.data?.code;
// Check for rate limiting
if ( if (
errorCode === "TOO_MANY_REQUESTS" || errorCode === "TOO_MANY_REQUESTS" ||
errorMsg.includes("Too many attempts") errorMsg.includes("Too many attempts")
) { ) {
setError(errorMsg); setError(errorMsg);
} } else if (
// Check for duplicate email
else if (
errorMsg.includes("duplicate") || errorMsg.includes("duplicate") ||
errorMsg.includes("already exists") errorMsg.includes("already exists")
) { ) {
@@ -194,7 +187,6 @@ export default function LoginPage() {
} }
} }
} else if (usePassword()) { } else if (usePassword()) {
// Password login flow
if (!emailRef || !passwordRef || !rememberMeRef) { if (!emailRef || !passwordRef || !rememberMeRef) {
setError("Please fill in all fields"); setError("Please fill in all fields");
setLoading(false); setLoading(false);
@@ -219,32 +211,25 @@ export default function LoginPage() {
navigate("/account", { replace: true }); navigate("/account", { replace: true });
}, 500); }, 500);
} else { } else {
// Handle specific error types
const errorMessage = result.error?.message || ""; const errorMessage = result.error?.message || "";
const errorCode = result.error?.data?.code; const errorCode = result.error?.data?.code;
// Check for rate limiting
if ( if (
errorCode === "TOO_MANY_REQUESTS" || errorCode === "TOO_MANY_REQUESTS" ||
errorMessage.includes("Too many attempts") errorMessage.includes("Too many attempts")
) { ) {
setError(errorMessage); setError(errorMessage);
} } else if (
// Check for account lockout
else if (
errorCode === "FORBIDDEN" || errorCode === "FORBIDDEN" ||
errorMessage.includes("Account locked") || errorMessage.includes("Account locked") ||
errorMessage.includes("Account is locked") errorMessage.includes("Account is locked")
) { ) {
setError(errorMessage); setError(errorMessage);
} } else {
// Generic login failure
else {
setShowPasswordError(true); setShowPasswordError(true);
} }
} }
} else { } else {
// Email link login flow
if (!emailRef || !rememberMeRef) { if (!emailRef || !rememberMeRef) {
setError("Please enter your email"); setError("Please enter your email");
setLoading(false); setLoading(false);
@@ -287,7 +272,6 @@ export default function LoginPage() {
"Failed to send email"; "Failed to send email";
const errorCode = result.error?.data?.code; const errorCode = result.error?.data?.code;
// Check for rate limiting or countdown not expired
if ( if (
errorCode === "TOO_MANY_REQUESTS" || errorCode === "TOO_MANY_REQUESTS" ||
errorMsg.includes("countdown not expired") || errorMsg.includes("countdown not expired") ||
@@ -342,9 +326,7 @@ export default function LoginPage() {
content="Sign in to your account or register for a new account to access personalized features and manage your profile." content="Sign in to your account or register for a new account to access personalized features and manage your profile."
/> />
<div class="flex h-dvh flex-row justify-evenly"> <div class="flex h-dvh flex-row justify-evenly">
{/* Main content */}
<div class="relative pt-12 md:pt-24"> <div class="relative pt-12 md:pt-24">
{/* Error message */}
<Show when={error()}> <Show when={error()}>
<div class="border-maroon bg-red mb-4 w-full max-w-md rounded-lg border px-4 py-3 text-center"> <div class="border-maroon bg-red mb-4 w-full max-w-md rounded-lg border px-4 py-3 text-center">
<Show when={error() === "passwordMismatch"}> <Show when={error() === "passwordMismatch"}>
@@ -389,12 +371,10 @@ export default function LoginPage() {
</div> </div>
</Show> </Show>
{/* Title */}
<div class="py-2 pl-6 text-2xl md:pl-0"> <div class="py-2 pl-6 text-2xl md:pl-0">
{register() ? "Register" : "Login"} {register() ? "Register" : "Login"}
</div> </div>
{/* Toggle Register/Login */}
<Show <Show
when={!register()} when={!register()}
fallback={ fallback={
@@ -420,9 +400,7 @@ export default function LoginPage() {
</div> </div>
</Show> </Show>
{/* Form */}
<form onSubmit={formHandler} class="flex flex-col px-2 py-4"> <form onSubmit={formHandler} class="flex flex-col px-2 py-4">
{/* Email input */}
<div class="flex justify-center"> <div class="flex justify-center">
<div class="input-group mx-4"> <div class="input-group mx-4">
<input <input
@@ -438,7 +416,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{/* Password input - shown for login with password or registration */}
<Show when={usePassword() || register()}> <Show when={usePassword() || register()}>
<div class="-mt-4 flex justify-center"> <div class="-mt-4 flex justify-center">
<div class="input-group mx-4 flex"> <div class="input-group mx-4 flex">
@@ -485,14 +462,12 @@ export default function LoginPage() {
</div> </div>
</Show> </Show>
{/* Password strength meter - shown only for registration */}
<Show when={register()}> <Show when={register()}>
<div class="mx-auto flex justify-center px-4 py-2"> <div class="mx-auto flex justify-center px-4 py-2">
<PasswordStrengthMeter password={password()} /> <PasswordStrengthMeter password={password()} />
</div> </div>
</Show> </Show>
{/* Password confirmation - shown only for registration */}
<Show when={register()}> <Show when={register()}>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="input-group mx-4"> <div class="input-group mx-4">
@@ -550,13 +525,11 @@ export default function LoginPage() {
</div> </div>
</Show> </Show>
{/* Remember Me checkbox */}
<div class="mx-auto flex pt-4"> <div class="mx-auto flex pt-4">
<input type="checkbox" class="my-auto" ref={rememberMeRef} /> <input type="checkbox" class="my-auto" ref={rememberMeRef} />
<div class="my-auto px-2 text-sm font-normal">Remember Me</div> <div class="my-auto px-2 text-sm font-normal">Remember Me</div>
</div> </div>
{/* Error/Success messages */}
<div <div
class={`${ class={`${
showPasswordError() showPasswordError()
@@ -574,7 +547,6 @@ export default function LoginPage() {
</Show> </Show>
</div> </div>
{/* Submit button or countdown timer */}
<div class="flex justify-center py-4"> <div class="flex justify-center py-4">
<Show <Show
when={!register() && !usePassword() && countDown() > 0} when={!register() && !usePassword() && countDown() > 0}
@@ -607,7 +579,6 @@ export default function LoginPage() {
</CountdownCircleTimer> </CountdownCircleTimer>
</Show> </Show>
{/* Toggle password/email link */}
<Show when={!register() && !usePassword()}> <Show when={!register() && !usePassword()}>
<A <A
href="/login?auth=password" href="/login?auth=password"
@@ -627,7 +598,6 @@ export default function LoginPage() {
</div> </div>
</form> </form>
{/* Password reset link */}
<Show when={usePassword()}> <Show when={usePassword()}>
<div class="pb-4 text-center text-sm"> <div class="pb-4 text-center text-sm">
Trouble Logging In?{" "} Trouble Logging In?{" "}
@@ -640,7 +610,6 @@ export default function LoginPage() {
</div> </div>
</Show> </Show>
{/* Email sent confirmation */}
<div <div
class={`${ class={`${
emailSent() ? "" : "user-select opacity-0" emailSent() ? "" : "user-select opacity-0"
@@ -649,10 +618,8 @@ export default function LoginPage() {
<Show when={emailSent()}>Email Sent!</Show> <Show when={emailSent()}>Email Sent!</Show>
</div> </div>
{/* Or divider */}
<div class="rule-around text-center">Or</div> <div class="rule-around text-center">Or</div>
{/* OAuth buttons */}
<div class="my-2 flex justify-center"> <div class="my-2 flex justify-center">
<div class="mx-auto mb-4 flex flex-col"> <div class="mx-auto mb-4 flex flex-col">
{/* Google OAuth */} {/* Google OAuth */}

View File

@@ -28,7 +28,6 @@ export default function PasswordResetPage() {
let newPasswordRef: HTMLInputElement | undefined; let newPasswordRef: HTMLInputElement | undefined;
let newPasswordConfRef: HTMLInputElement | undefined; let newPasswordConfRef: HTMLInputElement | undefined;
// Get token from URL
const token = searchParams.token; const token = searchParams.token;
createEffect(() => { createEffect(() => {
@@ -50,7 +49,6 @@ export default function PasswordResetPage() {
const newPassword = newPasswordRef.value; const newPassword = newPasswordRef.value;
const newPasswordConf = newPasswordConfRef.value; const newPasswordConf = newPasswordConfRef.value;
// Validate password
const passwordValidation = validatePassword(newPassword); const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.isValid) { if (!passwordValidation.isValid) {
setError(passwordValidation.errors[0] || "Invalid password"); setError(passwordValidation.errors[0] || "Invalid password");
@@ -140,7 +138,6 @@ export default function PasswordResetPage() {
passwordLengthBlurCheck(); passwordLengthBlurCheck();
}; };
// Render countdown timer
const renderTime = (timeRemaining: number) => { const renderTime = (timeRemaining: number) => {
if (timeRemaining === 0) { if (timeRemaining === 0) {
navigate("/login"); navigate("/login");
@@ -171,7 +168,6 @@ export default function PasswordResetPage() {
class="mt-4 flex w-full justify-center" class="mt-4 flex w-full justify-center"
> >
<div class="flex w-full max-w-md flex-col justify-center px-4"> <div class="flex w-full max-w-md flex-col justify-center px-4">
{/* New Password Input */}
<div class="flex justify-center"> <div class="flex justify-center">
<div class="input-group mx-4 flex"> <div class="input-group mx-4 flex">
<input <input
@@ -218,7 +214,6 @@ export default function PasswordResetPage() {
</button> </button>
</div> </div>
{/* Password Length Warning */}
<div <div
class={`${ class={`${
showPasswordLengthWarning() ? "" : "opacity-0 select-none" showPasswordLengthWarning() ? "" : "opacity-0 select-none"
@@ -228,7 +223,6 @@ export default function PasswordResetPage() {
{VALIDATION_CONFIG.MIN_PASSWORD_LENGTH} {VALIDATION_CONFIG.MIN_PASSWORD_LENGTH}
</div> </div>
{/* Password Confirmation Input */}
<div class="-mt-4 flex justify-center"> <div class="-mt-4 flex justify-center">
<div class="input-group mx-4 flex"> <div class="input-group mx-4 flex">
<input <input
@@ -275,7 +269,6 @@ export default function PasswordResetPage() {
</button> </button>
</div> </div>
{/* Password Mismatch Warning */}
<div <div
class={`${ class={`${
!passwordsMatch() && !passwordsMatch() &&
@@ -290,7 +283,6 @@ export default function PasswordResetPage() {
Passwords do not match! Passwords do not match!
</div> </div>
{/* Countdown Timer or Submit Button */}
<Show <Show
when={countDown()} when={countDown()}
fallback={ fallback={
@@ -323,14 +315,12 @@ export default function PasswordResetPage() {
</div> </div>
</form> </form>
{/* Error Message */}
<Show when={error() && !showRequestNewEmail()}> <Show when={error() && !showRequestNewEmail()}>
<div class="mt-4 flex justify-center"> <div class="mt-4 flex justify-center">
<div class="text-red text-sm italic">{error()}</div> <div class="text-red text-sm italic">{error()}</div>
</div> </div>
</Show> </Show>
{/* Token Expired Message */}
<div <div
class={`${ class={`${
showRequestNewEmail() ? "" : "opacity-0 select-none" showRequestNewEmail() ? "" : "opacity-0 select-none"
@@ -345,7 +335,6 @@ export default function PasswordResetPage() {
</A> </A>
</div> </div>
{/* Back to Login Link */}
<Show when={!countDown()}> <Show when={!countDown()}>
<div class="mt-6 flex justify-center"> <div class="mt-6 flex justify-center">
<A <A

View File

@@ -60,7 +60,6 @@ export default function RequestPasswordResetPage() {
const email = emailRef.value; const email = emailRef.value;
// Validate email
if (!isValidEmail(email)) { if (!isValidEmail(email)) {
setError("Invalid email address"); setError("Invalid email address");
return; return;
@@ -81,7 +80,6 @@ export default function RequestPasswordResetPage() {
setShowSuccessMessage(true); setShowSuccessMessage(true);
setError(""); setError("");
// Start countdown timer
const timer = getClientCookie("passwordResetRequested"); const timer = getClientCookie("passwordResetRequested");
if (timer) { if (timer) {
if (timerInterval) { if (timerInterval) {
@@ -95,15 +93,12 @@ export default function RequestPasswordResetPage() {
const errorMsg = result.error?.message || "Failed to send reset email"; const errorMsg = result.error?.message || "Failed to send reset email";
const errorCode = result.error?.data?.code; const errorCode = result.error?.data?.code;
// Handle rate limiting
if ( if (
errorCode === "TOO_MANY_REQUESTS" || errorCode === "TOO_MANY_REQUESTS" ||
errorMsg.includes("Too many attempts") errorMsg.includes("Too many attempts")
) { ) {
setError(errorMsg); setError(errorMsg);
} } else if (errorMsg.includes("countdown not expired")) {
// Handle countdown not expired
else if (errorMsg.includes("countdown not expired")) {
setError("Please wait before requesting another reset email"); setError("Please wait before requesting another reset email");
} else { } else {
setError(errorMsg); setError(errorMsg);
@@ -141,7 +136,6 @@ export default function RequestPasswordResetPage() {
class="mt-4 flex w-full justify-center" class="mt-4 flex w-full justify-center"
> >
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
{/* Email Input */}
<div class="input-group mx-4"> <div class="input-group mx-4">
<input <input
ref={emailRef} ref={emailRef}
@@ -157,7 +151,6 @@ export default function RequestPasswordResetPage() {
<label class="underlinedInputLabel">Enter Email</label> <label class="underlinedInputLabel">Enter Email</label>
</div> </div>
{/* Countdown Timer or Submit Button */}
<Show <Show
when={countDown() > 0} when={countDown() > 0}
fallback={ fallback={
@@ -191,7 +184,6 @@ export default function RequestPasswordResetPage() {
</div> </div>
</form> </form>
{/* Success Message */}
<div <div
class={`${ class={`${
showSuccessMessage() ? "" : "opacity-0 select-none" showSuccessMessage() ? "" : "opacity-0 select-none"
@@ -200,7 +192,6 @@ export default function RequestPasswordResetPage() {
If email exists, you will receive an email shortly! If email exists, you will receive an email shortly!
</div> </div>
{/* Error Message */}
<Show when={error()}> <Show when={error()}>
<div class="mt-4 flex justify-center"> <div class="mt-4 flex justify-center">
<div <div
@@ -230,7 +221,6 @@ export default function RequestPasswordResetPage() {
</div> </div>
</Show> </Show>
{/* Back to Login Link */}
<div class="mt-6 flex justify-center"> <div class="mt-6 flex justify-center">
<A <A
href="/login" href="/login"

View File

@@ -4,7 +4,6 @@ import { onCleanup, onMount } from "solid-js";
export default function Resume() { export default function Resume() {
let iframeRef: HTMLIFrameElement | undefined; let iframeRef: HTMLIFrameElement | undefined;
// this error kept happening in production, so I added this to prevent it, idk what was happening
onMount(() => { onMount(() => {
const handleError = (e: ErrorEvent) => { const handleError = (e: ErrorEvent) => {
if (e.filename?.includes("resume.pdf") || e.message === "Script error.") { if (e.filename?.includes("resume.pdf") || e.message === "Script error.") {

View File

@@ -8,10 +8,6 @@ import {
passwordsMatch passwordsMatch
} from "~/lib/validation"; } from "~/lib/validation";
/**
* Test page to validate Task 01 components and utilities
* Navigate to /test-utils to view
*/
export default function TestUtilsPage() { export default function TestUtilsPage() {
const [email, setEmail] = createSignal(""); const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal(""); const [password, setPassword] = createSignal("");
@@ -40,7 +36,6 @@ export default function TestUtilsPage() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
alert(`Form submitted!\nEmail: ${email()}\nPassword: ${password()}`); alert(`Form submitted!\nEmail: ${email()}\nPassword: ${password()}`);

View File

@@ -31,9 +31,6 @@ type RouterSection = {
}; };
const routerSections: RouterSection[] = [ const routerSections: RouterSection[] = [
// ============================================================
// Example Router
// ============================================================
{ {
name: "Example Router", name: "Example Router",
description: description:
@@ -66,9 +63,6 @@ const routerSections: RouterSection[] = [
] ]
}, },
// ============================================================
// Auth Router
// ============================================================
{ {
name: "Auth Router", name: "Auth Router",
description: "OAuth callbacks and email-based authentication", description: "OAuth callbacks and email-based authentication",
@@ -190,9 +184,6 @@ const routerSections: RouterSection[] = [
] ]
}, },
// ============================================================
// Database Router
// ============================================================
{ {
name: "Database - Comment Reactions", name: "Database - Comment Reactions",
description: "Add/remove reactions to comments", description: "Add/remove reactions to comments",
@@ -385,9 +376,6 @@ const routerSections: RouterSection[] = [
] ]
}, },
// ============================================================
// User Router
// ============================================================
{ {
name: "User Router", name: "User Router",
description: "User profile management and account operations", description: "User profile management and account operations",
@@ -464,9 +452,6 @@ const routerSections: RouterSection[] = [
] ]
}, },
// ============================================================
// Misc Router
// ============================================================
{ {
name: "Misc - Downloads", name: "Misc - Downloads",
description: "Generate signed URLs for downloadable assets", description: "Generate signed URLs for downloadable assets",
@@ -546,9 +531,6 @@ const routerSections: RouterSection[] = [
] ]
}, },
// ============================================================
// Lineage Router
// ============================================================
{ {
name: "Lineage - JSON Service", name: "Lineage - JSON Service",
description: "Static game data - no authentication required", description: "Static game data - no authentication required",
@@ -896,7 +878,6 @@ export default function TestPage() {
} }
} }
// Navigate the router path (handles nested routers like "lineage.auth")
const routerParts = endpoint.router.split("."); const routerParts = endpoint.router.split(".");
let currentRouter: any = api; let currentRouter: any = api;
@@ -914,7 +895,6 @@ export default function TestPage() {
); );
} }
// Call the tRPC procedure with proper method
const data = const data =
endpoint.method === "query" endpoint.method === "query"
? await procedure.query(input) ? await procedure.query(input)
@@ -997,7 +977,6 @@ export default function TestPage() {
</div> </div>
</button> </button>
{/* Section Content */}
<Show when={isExpanded()}> <Show when={isExpanded()}>
<div class="border-base space-y-4 border-t p-6"> <div class="border-base space-y-4 border-t p-6">
<For each={section.endpoints}> <For each={section.endpoints}>
@@ -1009,7 +988,6 @@ export default function TestPage() {
if (inputEdits()[key]) { if (inputEdits()[key]) {
return inputEdits()[key]; return inputEdits()[key];
} }
// Handle primitive values (string, number, boolean)
if (typeof endpoint.sampleInput === "string") { if (typeof endpoint.sampleInput === "string") {
return `"${endpoint.sampleInput}"`; return `"${endpoint.sampleInput}"`;
} }
@@ -1019,7 +997,6 @@ export default function TestPage() {
) { ) {
return String(endpoint.sampleInput); return String(endpoint.sampleInput);
} }
// Handle objects and arrays
return JSON.stringify( return JSON.stringify(
endpoint.sampleInput, endpoint.sampleInput,
null, null,
@@ -1029,7 +1006,6 @@ export default function TestPage() {
return ( return (
<div class="bg-surface2 border-surface1 rounded-lg border p-4"> <div class="bg-surface2 border-surface1 rounded-lg border p-4">
{/* Endpoint Header */}
<div class="mb-3 flex items-start justify-between"> <div class="mb-3 flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -1070,7 +1046,6 @@ export default function TestPage() {
</button> </button>
</div> </div>
{/* Input Editor */}
<Show when={hasInput}> <Show when={hasInput}>
<div class="mb-3"> <div class="mb-3">
<label class="text-text mb-1 block text-xs font-semibold"> <label class="text-text mb-1 block text-xs font-semibold">
@@ -1090,7 +1065,6 @@ export default function TestPage() {
</div> </div>
</Show> </Show>
{/* Error Display */}
<Show when={errors()[key]}> <Show when={errors()[key]}>
<div class="mb-3 rounded border border-red-200 bg-red-50 p-3"> <div class="mb-3 rounded border border-red-200 bg-red-50 p-3">
<p class="text-sm font-semibold text-red-800"> <p class="text-sm font-semibold text-red-800">
@@ -1102,7 +1076,6 @@ export default function TestPage() {
</div> </div>
</Show> </Show>
{/* Results Display */}
<Show when={results()[key]}> <Show when={results()[key]}>
<div class="rounded bg-gray-900 p-3"> <div class="rounded bg-gray-900 p-3">
<p class="mb-2 text-xs font-semibold text-green-400"> <p class="mb-2 text-xs font-semibold text-green-400">
@@ -1129,7 +1102,6 @@ export default function TestPage() {
</For> </For>
</div> </div>
{/* Footer Instructions */}
<div class="bg-overlay2 mt-6 rounded-lg p-6 shadow-lg"> <div class="bg-overlay2 mt-6 rounded-lg p-6 shadow-lg">
<h2 class="text-crust mb-4 text-2xl font-bold">Testing Guide</h2> <h2 class="text-crust mb-4 text-2xl font-bold">Testing Guide</h2>

View File

@@ -7,7 +7,6 @@ import { CACHE_CONFIG } from "~/config";
const BLOG_CACHE_TTL = CACHE_CONFIG.BLOG_CACHE_TTL_MS; const BLOG_CACHE_TTL = CACHE_CONFIG.BLOG_CACHE_TTL_MS;
// Shared cache function for all blog posts
const getAllPostsData = async (privilegeLevel: string) => { const getAllPostsData = async (privilegeLevel: string) => {
return withCacheAndStale( return withCacheAndStale(
`blog-posts-${privilegeLevel}`, `blog-posts-${privilegeLevel}`,
@@ -15,7 +14,6 @@ const getAllPostsData = async (privilegeLevel: string) => {
async () => { async () => {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Fetch all posts with aggregated data
let postsQuery = ` let postsQuery = `
SELECT SELECT
p.id, p.id,
@@ -73,10 +71,8 @@ const getAllPostsData = async (privilegeLevel: string) => {
export const blogRouter = createTRPCRouter({ export const blogRouter = createTRPCRouter({
getRecentPosts: publicProcedure.query(async ({ ctx }) => { getRecentPosts: publicProcedure.query(async ({ ctx }) => {
// Always use public privilege level for recent posts (only show published)
const allPostsData = await getAllPostsData("public"); const allPostsData = await getAllPostsData("public");
// Return only the 3 most recent posts (already sorted DESC by date)
return allPostsData.posts.slice(0, 3); return allPostsData.posts.slice(0, 3);
}), }),

View File

@@ -442,7 +442,6 @@ export const databaseRouter = createTRPCRouter({
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Check if post is being published for the first time
let shouldSetPublishDate = false; let shouldSetPublishDate = false;
if (input.published !== undefined && input.published !== null) { if (input.published !== undefined && input.published !== null) {
const currentPostQuery = await conn.execute({ const currentPostQuery = await conn.execute({
@@ -451,7 +450,6 @@ export const databaseRouter = createTRPCRouter({
}); });
const currentPost = currentPostQuery.rows[0] as any; const currentPost = currentPostQuery.rows[0] as any;
// Set publish date if transitioning from unpublished to published and date is null
if ( if (
currentPost && currentPost &&
!currentPost.published && !currentPost.published &&
@@ -500,14 +498,12 @@ export const databaseRouter = createTRPCRouter({
first = false; first = false;
} }
// Set date if publishing for the first time
if (shouldSetPublishDate) { if (shouldSetPublishDate) {
query += first ? "date = ?" : ", date = ?"; query += first ? "date = ?" : ", date = ?";
params.push(new Date().toISOString()); params.push(new Date().toISOString());
first = false; first = false;
} }
// Always update last_edited_date
query += first ? "last_edited_date = ?" : ", last_edited_date = ?"; query += first ? "last_edited_date = ?" : ", last_edited_date = ?";
params.push(new Date().toISOString()); params.push(new Date().toISOString());
first = false; first = false;
@@ -581,10 +577,6 @@ export const databaseRouter = createTRPCRouter({
} }
}), }),
// ============================================================
// Post Likes Routes
// ============================================================
addPostLike: publicProcedure addPostLike: publicProcedure
.input(togglePostLikeMutationSchema) .input(togglePostLikeMutationSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -640,10 +632,6 @@ export const databaseRouter = createTRPCRouter({
} }
}), }),
// ============================================================
// User Routes
// ============================================================
getUserById: publicProcedure getUserById: publicProcedure
.input(getUserByIdSchema) .input(getUserByIdSchema)
.query(async ({ input }) => { .query(async ({ input }) => {

View File

@@ -226,7 +226,6 @@ export const gitActivityRouter = createTRPCRouter({
}); });
}), }),
// Get GitHub contribution activity (for heatmap)
getGitHubActivity: publicProcedure.query(async () => { getGitHubActivity: publicProcedure.query(async () => {
return withCacheAndStale( return withCacheAndStale(
"github-activity", "github-activity",
@@ -306,7 +305,6 @@ export const gitActivityRouter = createTRPCRouter({
}); });
}), }),
// Get Gitea contribution activity (for heatmap)
getGiteaActivity: publicProcedure.query(async () => { getGiteaActivity: publicProcedure.query(async () => {
return withCacheAndStale( return withCacheAndStale(
"gitea-activity", "gitea-activity",

View File

@@ -3,13 +3,10 @@ import { env } from "~/env/server";
export const infillRouter = createTRPCRouter({ export const infillRouter = createTRPCRouter({
getConfig: publicProcedure.query(({ ctx }) => { getConfig: publicProcedure.query(({ ctx }) => {
// Only admins get the config
if (ctx.privilegeLevel !== "admin") { if (ctx.privilegeLevel !== "admin") {
return { endpoint: null, token: null }; return { endpoint: null, token: null };
} }
// Return endpoint and token (or null if not configured)
// Now supports both desktop and mobile (fullscreen mode)
return { return {
endpoint: env.VITE_INFILL_ENDPOINT || null, endpoint: env.VITE_INFILL_ENDPOINT || null,
token: env.INFILL_BEARER_TOKEN || null token: env.INFILL_BEARER_TOKEN || null

View File

@@ -7,21 +7,15 @@ import { lineagePvpRouter } from "./lineage/pvp";
import { lineageMaintenanceRouter } from "./lineage/maintenance"; import { lineageMaintenanceRouter } from "./lineage/maintenance";
export const lineageRouter = createTRPCRouter({ export const lineageRouter = createTRPCRouter({
// Authentication
auth: lineageAuthRouter, auth: lineageAuthRouter,
// Database Management
database: lineageDatabaseRouter, database: lineageDatabaseRouter,
// PvP
pvp: lineagePvpRouter, pvp: lineagePvpRouter,
// JSON Service
jsonService: lineageJsonServiceRouter, jsonService: lineageJsonServiceRouter,
// Misc (Analytics, Tokens, etc.)
misc: lineageMiscRouter, misc: lineageMiscRouter,
// Maintenance (Protected) maintenance: lineageMaintenanceRouter
maintenance: lineageMaintenanceRouter,
}); });

View File

@@ -427,7 +427,6 @@ export const lineageDatabaseRouter = createTRPCRouter({
`Failed to delete database ${db_name} in cron job:`, `Failed to delete database ${db_name} in cron job:`,
error error
); );
// Continue with other deletions even if one fails
} }
} }
} else { } else {
@@ -455,7 +454,6 @@ export const lineageDatabaseRouter = createTRPCRouter({
`Failed to delete database ${db_name} in cron job:`, `Failed to delete database ${db_name} in cron job:`,
error error
); );
// Continue with other deletions even if one fails
} }
} }
} }

View File

@@ -1,6 +1,5 @@
import { createTRPCRouter, publicProcedure } from "../../utils"; import { createTRPCRouter, publicProcedure } from "../../utils";
// Attack data imports
import playerAttacks from "~/lineage-json/attack-route/playerAttacks.json"; import playerAttacks from "~/lineage-json/attack-route/playerAttacks.json";
import mageBooks from "~/lineage-json/attack-route/mageBooks.json"; import mageBooks from "~/lineage-json/attack-route/mageBooks.json";
import mageSpells from "~/lineage-json/attack-route/mageSpells.json"; import mageSpells from "~/lineage-json/attack-route/mageSpells.json";
@@ -12,21 +11,17 @@ import paladinBooks from "~/lineage-json/attack-route/paladinBooks.json";
import paladinSpells from "~/lineage-json/attack-route/paladinSpells.json"; import paladinSpells from "~/lineage-json/attack-route/paladinSpells.json";
import summons from "~/lineage-json/attack-route/summons.json"; import summons from "~/lineage-json/attack-route/summons.json";
// Conditions data imports
import conditions from "~/lineage-json/conditions-route/conditions.json"; import conditions from "~/lineage-json/conditions-route/conditions.json";
import debilitations from "~/lineage-json/conditions-route/debilitations.json"; import debilitations from "~/lineage-json/conditions-route/debilitations.json";
import sanityDebuffs from "~/lineage-json/conditions-route/sanityDebuffs.json"; import sanityDebuffs from "~/lineage-json/conditions-route/sanityDebuffs.json";
// Dungeon data imports
import dungeons from "~/lineage-json/dungeon-route/dungeons.json"; import dungeons from "~/lineage-json/dungeon-route/dungeons.json";
import specialEncounters from "~/lineage-json/dungeon-route/specialEncounters.json"; import specialEncounters from "~/lineage-json/dungeon-route/specialEncounters.json";
// Enemy data imports
import bosses from "~/lineage-json/enemy-route/bosses.json"; import bosses from "~/lineage-json/enemy-route/bosses.json";
import enemies from "~/lineage-json/enemy-route/enemy.json"; import enemies from "~/lineage-json/enemy-route/enemy.json";
import enemyAttacks from "~/lineage-json/enemy-route/enemyAttacks.json"; import enemyAttacks from "~/lineage-json/enemy-route/enemyAttacks.json";
// Item data imports
import arrows from "~/lineage-json/item-route/arrows.json"; import arrows from "~/lineage-json/item-route/arrows.json";
import bows from "~/lineage-json/item-route/bows.json"; import bows from "~/lineage-json/item-route/bows.json";
import foci from "~/lineage-json/item-route/foci.json"; import foci from "~/lineage-json/item-route/foci.json";
@@ -69,7 +64,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
rangerSpells, rangerSpells,
paladinBooks, paladinBooks,
paladinSpells, paladinSpells,
summons, summons
}; };
}), }),
@@ -78,7 +73,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
ok: true, ok: true,
conditions, conditions,
debilitations, debilitations,
sanityDebuffs, sanityDebuffs
}; };
}), }),
@@ -86,7 +81,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
return { return {
ok: true, ok: true,
dungeons, dungeons,
specialEncounters, specialEncounters
}; };
}), }),
@@ -95,7 +90,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
ok: true, ok: true,
bosses, bosses,
enemies, enemies,
enemyAttacks, enemyAttacks
}; };
}), }),
@@ -120,7 +115,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
prefix, prefix,
potions, potions,
poison, poison,
staves, staves
}; };
}), }),
@@ -134,7 +129,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
otherOptions, otherOptions,
healthOptions, healthOptions,
sanityOptions, sanityOptions,
pvpRewards, pvpRewards
}; };
}), })
}); });

View File

@@ -28,10 +28,6 @@ const assets: Record<string, string> = {
}; };
export const miscRouter = createTRPCRouter({ export const miscRouter = createTRPCRouter({
// ============================================================
// Downloads endpoint
// ============================================================
getDownloadUrl: publicProcedure getDownloadUrl: publicProcedure
.input(z.object({ asset_name: z.string() })) .input(z.object({ asset_name: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
@@ -73,10 +69,6 @@ export const miscRouter = createTRPCRouter({
} }
}), }),
// ============================================================
// S3 Operations
// ============================================================
getPreSignedURL: publicProcedure getPreSignedURL: publicProcedure
.input( .input(
z.object({ z.object({
@@ -99,10 +91,10 @@ export const miscRouter = createTRPCRouter({
const sanitizeForS3 = (str: string) => { const sanitizeForS3 = (str: string) => {
return str return str
.replace(/\s+/g, "-") // Replace spaces with hyphens .replace(/\s+/g, "-")
.replace(/[^\w\-\.]/g, "") // Remove special characters except hyphens, dots, and word chars .replace(/[^\w\-\.]/g, "")
.replace(/\-+/g, "-") // Replace multiple hyphens with single hyphen .replace(/\-+/g, "-")
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens .replace(/^-+|-+$/g, "");
}; };
const sanitizedTitle = sanitizeForS3(input.title); const sanitizedTitle = sanitizeForS3(input.title);
@@ -210,10 +202,6 @@ export const miscRouter = createTRPCRouter({
} }
}), }),
// ============================================================
// Password Hashing
// ============================================================
hashPassword: publicProcedure hashPassword: publicProcedure
.input(z.object({ password: z.string().min(8) })) .input(z.object({ password: z.string().min(8) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -249,10 +237,6 @@ export const miscRouter = createTRPCRouter({
} }
}), }),
// ============================================================
// Contact Form
// ============================================================
sendContactRequest: publicProcedure sendContactRequest: publicProcedure
.input( .input(
z.object({ z.object({
@@ -324,7 +308,6 @@ export const miscRouter = createTRPCRouter({
return { message: "email sent" }; return { message: "email sent" };
} catch (error) { } catch (error) {
// Provide specific error messages for different failure types
if (error instanceof TimeoutError) { if (error instanceof TimeoutError) {
console.error("Contact form email timeout:", error.message); console.error("Contact form email timeout:", error.message);
throw new TRPCError({ throw new TRPCError({
@@ -359,10 +342,6 @@ export const miscRouter = createTRPCRouter({
} }
}), }),
// ============================================================
// Account Deletion Request
// ============================================================
sendDeletionRequestEmail: publicProcedure sendDeletionRequestEmail: publicProcedure
.input(z.object({ email: z.string().email() })) .input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -384,7 +363,6 @@ export const miscRouter = createTRPCRouter({
const apiKey = env.SENDINBLUE_KEY; const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
// Email to admin
const sendinblueMyData = { const sendinblueMyData = {
sender: { sender: {
name: "freno.me", name: "freno.me",
@@ -395,7 +373,6 @@ export const miscRouter = createTRPCRouter({
subject: "Life and Lineage Acct Deletion" subject: "Life and Lineage Acct Deletion"
}; };
// Email to user
const sendinblueUserData = { const sendinblueUserData = {
sender: { sender: {
name: "freno.me", name: "freno.me",
@@ -407,7 +384,6 @@ export const miscRouter = createTRPCRouter({
}; };
try { try {
// Send both emails with retry logic
await Promise.all([ await Promise.all([
fetchWithRetry( fetchWithRetry(
async () => { async () => {
@@ -459,7 +435,6 @@ export const miscRouter = createTRPCRouter({
return { message: "request sent" }; return { message: "request sent" };
} catch (error) { } catch (error) {
// Provide specific error messages
if (error instanceof TimeoutError) { if (error instanceof TimeoutError) {
console.error("Deletion request email timeout:", error.message); console.error("Deletion request email timeout:", error.message);
throw new TRPCError({ throw new TRPCError({

View File

@@ -5,7 +5,6 @@ import { getUserID } from "~/server/auth";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import diff from "fast-diff"; import diff from "fast-diff";
// Helper to create diff patch between two HTML strings
export function createDiffPatch( export function createDiffPatch(
oldContent: string, oldContent: string,
newContent: string newContent: string
@@ -14,7 +13,6 @@ export function createDiffPatch(
return JSON.stringify(changes); return JSON.stringify(changes);
} }
// Helper to apply diff patch to content
export function applyDiffPatch(baseContent: string, patchJson: string): string { export function applyDiffPatch(baseContent: string, patchJson: string): string {
const changes = JSON.parse(patchJson); const changes = JSON.parse(patchJson);
let result = ""; let result = "";
@@ -34,12 +32,10 @@ export function applyDiffPatch(baseContent: string, patchJson: string): string {
return result; return result;
} }
// Helper to reconstruct content from history chain
async function reconstructContent( async function reconstructContent(
conn: ReturnType<typeof ConnectionFactory>, conn: ReturnType<typeof ConnectionFactory>,
historyId: number historyId: number
): Promise<string> { ): Promise<string> {
// Get the full chain from root to this history entry
const chain: Array<{ const chain: Array<{
id: number; id: number;
parent_id: number | null; parent_id: number | null;
@@ -70,7 +66,6 @@ async function reconstructContent(
currentId = row.parent_id; currentId = row.parent_id;
} }
// Apply patches in order
let content = ""; let content = "";
for (const entry of chain) { for (const entry of chain) {
content = applyDiffPatch(content, entry.content); content = applyDiffPatch(content, entry.content);
@@ -80,7 +75,6 @@ async function reconstructContent(
} }
export const postHistoryRouter = createTRPCRouter({ export const postHistoryRouter = createTRPCRouter({
// Save a new history entry
save: publicProcedure save: publicProcedure
.input( .input(
z.object({ z.object({
@@ -124,10 +118,8 @@ export const postHistoryRouter = createTRPCRouter({
}); });
} }
// Create diff patch
const diffPatch = createDiffPatch(input.previousContent, input.content); const diffPatch = createDiffPatch(input.previousContent, input.content);
// Insert history entry
const result = await conn.execute({ const result = await conn.execute({
sql: ` sql: `
INSERT INTO PostHistory (post_id, parent_id, content, is_saved) INSERT INTO PostHistory (post_id, parent_id, content, is_saved)
@@ -141,7 +133,6 @@ export const postHistoryRouter = createTRPCRouter({
] ]
}); });
// Prune old history entries if we exceed 100
const countResult = await conn.execute({ const countResult = await conn.execute({
sql: "SELECT COUNT(*) as count FROM PostHistory WHERE post_id = ?", sql: "SELECT COUNT(*) as count FROM PostHistory WHERE post_id = ?",
args: [input.postId] args: [input.postId]
@@ -149,7 +140,6 @@ export const postHistoryRouter = createTRPCRouter({
const count = (countResult.rows[0] as { count: number }).count; const count = (countResult.rows[0] as { count: number }).count;
if (count > 100) { if (count > 100) {
// Get the oldest entries to delete (keep most recent 100)
const toDelete = await conn.execute({ const toDelete = await conn.execute({
sql: ` sql: `
SELECT id FROM PostHistory SELECT id FROM PostHistory
@@ -160,7 +150,6 @@ export const postHistoryRouter = createTRPCRouter({
args: [input.postId, count - 100] args: [input.postId, count - 100]
}); });
// Delete old entries
for (const row of toDelete.rows) { for (const row of toDelete.rows) {
const entry = row as { id: number }; const entry = row as { id: number };
await conn.execute({ await conn.execute({
@@ -176,7 +165,6 @@ export const postHistoryRouter = createTRPCRouter({
}; };
}), }),
// Get history for a post with reconstructed content
getHistory: publicProcedure getHistory: publicProcedure
.input(z.object({ postId: z.number() })) .input(z.object({ postId: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
@@ -191,7 +179,6 @@ export const postHistoryRouter = createTRPCRouter({
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Verify post exists and user is author
const postCheck = await conn.execute({ const postCheck = await conn.execute({
sql: "SELECT author_id FROM Post WHERE id = ?", sql: "SELECT author_id FROM Post WHERE id = ?",
args: [input.postId] args: [input.postId]
@@ -212,7 +199,6 @@ export const postHistoryRouter = createTRPCRouter({
}); });
} }
// Get all history entries for this post
const result = await conn.execute({ const result = await conn.execute({
sql: ` sql: `
SELECT id, parent_id, content, created_at, is_saved SELECT id, parent_id, content, created_at, is_saved
@@ -231,7 +217,6 @@ export const postHistoryRouter = createTRPCRouter({
is_saved: number; is_saved: number;
}>; }>;
// Reconstruct content for each entry by applying diffs sequentially
const historyWithContent: Array<{ const historyWithContent: Array<{
id: number; id: number;
parent_id: number | null; parent_id: number | null;
@@ -255,7 +240,6 @@ export const postHistoryRouter = createTRPCRouter({
return historyWithContent; return historyWithContent;
}), }),
// Restore content from a history entry
restore: publicProcedure restore: publicProcedure
.input(z.object({ historyId: z.number() })) .input(z.object({ historyId: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
@@ -270,7 +254,6 @@ export const postHistoryRouter = createTRPCRouter({
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Get history entry and verify ownership
const historyResult = await conn.execute({ const historyResult = await conn.execute({
sql: ` sql: `
SELECT ph.post_id SELECT ph.post_id
@@ -288,12 +271,9 @@ export const postHistoryRouter = createTRPCRouter({
}); });
} }
const historyEntry = historyResult.rows[0] as { post_id: number };
// Verify user is post author
const postCheck = await conn.execute({ const postCheck = await conn.execute({
sql: "SELECT author_id FROM Post WHERE id = ?", sql: "SELECT author_id FROM Post WHERE id = ?",
args: [historyEntry.post_id] args: [historyResult.post_id]
}); });
const post = postCheck.rows[0] as { author_id: string }; const post = postCheck.rows[0] as { author_id: string };
@@ -304,7 +284,6 @@ export const postHistoryRouter = createTRPCRouter({
}); });
} }
// Reconstruct content from history chain
const content = await reconstructContent(conn, input.historyId); const content = await reconstructContent(conn, input.historyId);
return { content }; return { content };

View File

@@ -152,7 +152,6 @@ export const userRouter = createTRPCRouter({
const { oldPassword, newPassword, newPasswordConfirmation } = input; const { oldPassword, newPassword, newPasswordConfirmation } = input;
// Schema already validates password match, but double check
if (newPassword !== newPasswordConfirmation) { if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -226,7 +225,6 @@ export const userRouter = createTRPCRouter({
const { newPassword, newPasswordConfirmation } = input; const { newPassword, newPasswordConfirmation } = input;
// Schema already validates password match, but double check
if (newPassword !== newPasswordConfirmation) { if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",

View File

@@ -26,7 +26,6 @@ async function createContextInner(event: APIEvent): Promise<Context> {
privilegeLevel = payload.id === env.ADMIN_ID ? "admin" : "user"; privilegeLevel = payload.id === env.ADMIN_ID ? "admin" : "user";
} }
} catch (err) { } catch (err) {
// Silently clear invalid token (401s are expected for non-authenticated users)
setCookie(event.nativeEvent, "userIDToken", "", { setCookie(event.nativeEvent, "userIDToken", "", {
maxAge: 0, maxAge: 0,
expires: new Date("2016-10-05") expires: new Date("2016-10-05")
@@ -57,7 +56,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
return next({ return next({
ctx: { ctx: {
...ctx, ...ctx,
userId: ctx.userId // userId is non-null here userId: ctx.userId
} }
}); });
}); });
@@ -72,11 +71,10 @@ const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
return next({ return next({
ctx: { ctx: {
...ctx, ...ctx,
userId: ctx.userId! // userId is non-null for admins userId: ctx.userId!
} }
}); });
}); });
// Protected procedures
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const adminProcedure = t.procedure.use(enforceUserIsAdmin); export const adminProcedure = t.procedure.use(enforceUserIsAdmin);

View File

@@ -10,32 +10,25 @@ import { v4 as uuid } from "uuid";
* Audit event types for security tracking * Audit event types for security tracking
*/ */
export type AuditEventType = export type AuditEventType =
// Authentication events
| "auth.login.success" | "auth.login.success"
| "auth.login.failed" | "auth.login.failed"
| "auth.logout" | "auth.logout"
| "auth.register.success" | "auth.register.success"
| "auth.register.failed" | "auth.register.failed"
// Password events
| "auth.password.change" | "auth.password.change"
| "auth.password.reset.request" | "auth.password.reset.request"
| "auth.password.reset.complete" | "auth.password.reset.complete"
// Email verification
| "auth.email.verify.request" | "auth.email.verify.request"
| "auth.email.verify.complete" | "auth.email.verify.complete"
// OAuth events
| "auth.oauth.github.success" | "auth.oauth.github.success"
| "auth.oauth.github.failed" | "auth.oauth.github.failed"
| "auth.oauth.google.success" | "auth.oauth.google.success"
| "auth.oauth.google.failed" | "auth.oauth.google.failed"
// Session management
| "auth.session.revoke" | "auth.session.revoke"
| "auth.session.revokeAll" | "auth.session.revokeAll"
// Security events
| "security.rate_limit.exceeded" | "security.rate_limit.exceeded"
| "security.csrf.failed" | "security.csrf.failed"
| "security.suspicious.activity" | "security.suspicious.activity"
// Admin actions
| "admin.action"; | "admin.action";
/** /**
@@ -74,7 +67,6 @@ export async function logAuditEvent(entry: AuditLogEntry): Promise<void> {
] ]
}); });
} catch (error) { } catch (error) {
// Never throw - logging failures shouldn't break auth flows
console.error("Failed to write audit log:", error, entry); console.error("Failed to write audit log:", error, entry);
} }
} }
@@ -186,7 +178,6 @@ export async function getFailedLoginAttempts(
): Promise<number | Array<Record<string, any>>> { ): Promise<number | Array<Record<string, any>>> {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Aggregate query: getFailedLoginAttempts(24, 100) - get all failed logins in last 24 hours
if ( if (
typeof identifierOrHours === "number" && typeof identifierOrHours === "number" &&
typeof identifierTypeOrLimit === "number" typeof identifierTypeOrLimit === "number"
@@ -216,7 +207,6 @@ export async function getFailedLoginAttempts(
})); }));
} }
// Specific identifier query: getFailedLoginAttempts("user-123", "user_id", 15)
const identifier = identifierOrHours as string; const identifier = identifierOrHours as string;
const identifierType = identifierTypeOrLimit as "user_id" | "ip_address"; const identifierType = identifierTypeOrLimit as "user_id" | "ip_address";
const column = identifierType === "user_id" ? "user_id" : "ip_address"; const column = identifierType === "user_id" ? "user_id" : "ip_address";
@@ -258,7 +248,6 @@ export async function getUserSecuritySummary(
}> { }> {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Get total events for user in time period
const totalEventsResult = await conn.execute({ const totalEventsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -267,7 +256,6 @@ export async function getUserSecuritySummary(
}); });
const totalEvents = (totalEventsResult.rows[0]?.count as number) || 0; const totalEvents = (totalEventsResult.rows[0]?.count as number) || 0;
// Get successful events
const successfulEventsResult = await conn.execute({ const successfulEventsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -278,7 +266,6 @@ export async function getUserSecuritySummary(
const successfulEvents = const successfulEvents =
(successfulEventsResult.rows[0]?.count as number) || 0; (successfulEventsResult.rows[0]?.count as number) || 0;
// Get failed events
const failedEventsResult = await conn.execute({ const failedEventsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -288,7 +275,6 @@ export async function getUserSecuritySummary(
}); });
const failedEvents = (failedEventsResult.rows[0]?.count as number) || 0; const failedEvents = (failedEventsResult.rows[0]?.count as number) || 0;
// Get unique event types
const eventTypesResult = await conn.execute({ const eventTypesResult = await conn.execute({
sql: `SELECT DISTINCT event_type FROM AuditLog sql: `SELECT DISTINCT event_type FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -299,7 +285,6 @@ export async function getUserSecuritySummary(
(row) => row.event_type as string (row) => row.event_type as string
); );
// Get unique IPs
const uniqueIPsResult = await conn.execute({ const uniqueIPsResult = await conn.execute({
sql: `SELECT DISTINCT ip_address FROM AuditLog sql: `SELECT DISTINCT ip_address FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -309,7 +294,6 @@ export async function getUserSecuritySummary(
}); });
const uniqueIPs = uniqueIPsResult.rows.map((row) => row.ip_address as string); const uniqueIPs = uniqueIPsResult.rows.map((row) => row.ip_address as string);
// Get total successful logins
const loginResult = await conn.execute({ const loginResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -319,7 +303,6 @@ export async function getUserSecuritySummary(
}); });
const totalLogins = (loginResult.rows[0]?.count as number) || 0; const totalLogins = (loginResult.rows[0]?.count as number) || 0;
// Get failed login attempts
const failedResult = await conn.execute({ const failedResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -330,7 +313,6 @@ export async function getUserSecuritySummary(
}); });
const failedLogins = (failedResult.rows[0]?.count as number) || 0; const failedLogins = (failedResult.rows[0]?.count as number) || 0;
// Get last login info
const lastLoginResult = await conn.execute({ const lastLoginResult = await conn.execute({
sql: `SELECT created_at, ip_address FROM AuditLog sql: `SELECT created_at, ip_address FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -342,7 +324,6 @@ export async function getUserSecuritySummary(
}); });
const lastLogin = lastLoginResult.rows[0]; const lastLogin = lastLoginResult.rows[0];
// Get unique IP count
const ipResult = await conn.execute({ const ipResult = await conn.execute({
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM AuditLog sql: `SELECT COUNT(DISTINCT ip_address) as count FROM AuditLog
WHERE user_id = ? WHERE user_id = ?
@@ -353,7 +334,6 @@ export async function getUserSecuritySummary(
}); });
const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0; const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0;
// Get recent sessions (last 24 hours)
const sessionResult = await conn.execute({ const sessionResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ? WHERE user_id = ?

View File

@@ -4,7 +4,6 @@ export interface FeatureFlags {
export function getFeatureFlags(): FeatureFlags { export function getFeatureFlags(): FeatureFlags {
return { return {
// TODO: Add feature flags here
"beta-features": process.env.ENABLE_BETA_FEATURES === "true", "beta-features": process.env.ENABLE_BETA_FEATURES === "true",
"new-editor": false, "new-editor": false,
"premium-content": true, "premium-content": true,

View File

@@ -1,10 +1,3 @@
/**
* Database Initialization for Audit Logging
* Run this script to create the AuditLog table in your database
*
* Usage: bun run src/server/init-audit-table.ts
*/
import { ConnectionFactory } from "./database"; import { ConnectionFactory } from "./database";
async function initAuditTable() { async function initAuditTable() {
@@ -13,7 +6,6 @@ async function initAuditTable() {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Create AuditLog table
await conn.execute({ await conn.execute({
sql: `CREATE TABLE IF NOT EXISTS AuditLog ( sql: `CREATE TABLE IF NOT EXISTS AuditLog (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -30,7 +22,6 @@ async function initAuditTable() {
console.log("✅ AuditLog table created (or already exists)"); console.log("✅ AuditLog table created (or already exists)");
// Create indexes for performance
console.log("🔧 Creating indexes..."); console.log("🔧 Creating indexes...");
await conn.execute({ await conn.execute({
@@ -51,7 +42,6 @@ async function initAuditTable() {
console.log("✅ Indexes created"); console.log("✅ Indexes created");
// Verify table exists
const result = await conn.execute({ const result = await conn.execute({
sql: `SELECT name FROM sqlite_master WHERE type='table' AND name='AuditLog'` sql: `SELECT name FROM sqlite_master WHERE type='table' AND name='AuditLog'`
}); });
@@ -59,7 +49,6 @@ async function initAuditTable() {
if (result.rows.length > 0) { if (result.rows.length > 0) {
console.log("✅ AuditLog table verified - ready for use!"); console.log("✅ AuditLog table verified - ready for use!");
// Check row count
const countResult = await conn.execute({ const countResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog` sql: `SELECT COUNT(*) as count FROM AuditLog`
}); });
@@ -82,7 +71,6 @@ async function initAuditTable() {
} }
} }
// Run if executed directly
if (import.meta.main) { if (import.meta.main) {
initAuditTable() initAuditTable()
.then(() => process.exit(0)) .then(() => process.exit(0))

View File

@@ -1,10 +1,5 @@
import * as bcrypt from "bcrypt"; import * as bcrypt from "bcrypt";
/**
* Dummy hash for timing attack prevention
* This is a pre-computed bcrypt hash that will be used when a user doesn't exist
* to maintain constant-time behavior
*/
const DUMMY_HASH = const DUMMY_HASH =
"$2b$10$YxVvS6L6HhS1pVBP6nZK0.9r0xwN8xvvzX7GwL5xvKJ6xvS6L6HhS1"; "$2b$10$YxVvS6L6HhS1pVBP6nZK0.9r0xwN8xvvzX7GwL5xvKJ6xvS6L6HhS1";
@@ -25,16 +20,13 @@ export async function checkPassword(
/** /**
* Check password with timing attack protection * Check password with timing attack protection
* Always runs bcrypt comparison even if user doesn't exist
*/ */
export async function checkPasswordSafe( export async function checkPasswordSafe(
password: string, password: string,
hash: string | null | undefined hash: string | null | undefined
): Promise<boolean> { ): Promise<boolean> {
// If no hash provided, use dummy hash to maintain constant timing
const hashToCompare = hash || DUMMY_HASH; const hashToCompare = hash || DUMMY_HASH;
const match = await bcrypt.compare(password, hashToCompare); const match = await bcrypt.compare(password, hashToCompare);
// Return false if no real hash was provided
return hash ? match : false; return hash ? match : false;
} }

View File

@@ -17,14 +17,10 @@ import {
*/ */
function getCookieValue(event: H3Event, name: string): string | undefined { function getCookieValue(event: H3Event, name: string): string | undefined {
try { try {
// Try vinxi's getCookie first
const value = getCookie(event, name); const value = getCookie(event, name);
if (value) return value; if (value) return value;
} catch (e) { } catch (e) {}
// vinxi's getCookie failed, will use fallback
}
// Fallback for tests: parse cookie header manually
try { try {
const cookieHeader = const cookieHeader =
event.headers?.get?.("cookie") || event.headers?.get?.("cookie") ||
@@ -60,7 +56,6 @@ function setCookieValue(
try { try {
setCookie(event, name, value, options); setCookie(event, name, value, options);
} catch (e) { } catch (e) {
// In tests, setCookie might fail - store in mock object
if (!event.node) event.node = { req: { headers: {} } } as any; if (!event.node) event.node = { req: { headers: {} } } as any;
if (!event.node.res) event.node.res = {} as any; if (!event.node.res) event.node.res = {} as any;
if (!event.node.res.cookies) event.node.res.cookies = {} as any; if (!event.node.res.cookies) event.node.res.cookies = {} as any;
@@ -73,18 +68,15 @@ function setCookieValue(
*/ */
function getHeaderValue(event: H3Event, name: string): string | null { function getHeaderValue(event: H3Event, name: string): string | null {
try { try {
// Try various header access patterns
if (event.request?.headers?.get) { if (event.request?.headers?.get) {
const val = event.request.headers.get(name); const val = event.request.headers.get(name);
if (val !== null && val !== undefined) return val; if (val !== null && val !== undefined) return val;
} }
if (event.headers) { if (event.headers) {
// Check if it's a Headers object with .get method
if (typeof (event.headers as any).get === "function") { if (typeof (event.headers as any).get === "function") {
const val = (event.headers as any).get(name); const val = (event.headers as any).get(name);
if (val !== null && val !== undefined) return val; if (val !== null && val !== undefined) return val;
} }
// Or a plain object
if (typeof event.headers === "object") { if (typeof event.headers === "object") {
const val = (event.headers as any)[name]; const val = (event.headers as any)[name];
if (val !== undefined) return val; if (val !== undefined) return val;
@@ -115,7 +107,7 @@ export function setCSRFToken(event: H3Event): string {
setCookieValue(event, "csrf-token", token, { setCookieValue(event, "csrf-token", token, {
maxAge: AUTH_CONFIG.CSRF_TOKEN_MAX_AGE, maxAge: AUTH_CONFIG.CSRF_TOKEN_MAX_AGE,
path: "/", path: "/",
httpOnly: false, // Must be readable by client JS httpOnly: false,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
sameSite: "lax" sameSite: "lax"
}); });
@@ -133,7 +125,6 @@ export function validateCSRFToken(event: H3Event): boolean {
return false; return false;
} }
// Constant-time comparison to prevent timing attacks
return timingSafeEqual(headerToken, cookieToken); return timingSafeEqual(headerToken, cookieToken);
} }
@@ -160,7 +151,6 @@ export const csrfProtection = t.middleware(async ({ ctx, next }) => {
const isValid = validateCSRFToken(ctx.event.nativeEvent); const isValid = validateCSRFToken(ctx.event.nativeEvent);
if (!isValid) { if (!isValid) {
// Log CSRF failure
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({ await logAuditEvent({
eventType: "security.csrf.failed", eventType: "security.csrf.failed",
@@ -191,8 +181,6 @@ export const csrfProtection = t.middleware(async ({ ctx, next }) => {
*/ */
export const csrfProtectedProcedure = t.procedure.use(csrfProtection); export const csrfProtectedProcedure = t.procedure.use(csrfProtection);
// ========== Rate Limiting ==========
interface RateLimitRecord { interface RateLimitRecord {
count: number; count: number;
resetAt: number; resetAt: number;
@@ -213,7 +201,6 @@ export async function clearRateLimitStore(): Promise<void> {
/** /**
* Cleanup expired rate limit entries every 5 minutes * Cleanup expired rate limit entries every 5 minutes
* Runs in background to prevent database bloat
*/ */
setInterval(async () => { setInterval(async () => {
try { try {
@@ -255,7 +242,6 @@ export function getUserAgent(event: H3Event): string {
/** /**
* Extract audit context from H3Event * Extract audit context from H3Event
* Convenience function for logging
*/ */
export function getAuditContext(event: H3Event): { export function getAuditContext(event: H3Event): {
ipAddress: string; ipAddress: string;
@@ -288,14 +274,12 @@ export async function checkRateLimit(
const now = Date.now(); const now = Date.now();
const resetAt = new Date(now + windowMs); const resetAt = new Date(now + windowMs);
// Try to get existing record
const result = await conn.execute({ const result = await conn.execute({
sql: "SELECT id, count, reset_at FROM RateLimit WHERE identifier = ?", sql: "SELECT id, count, reset_at FROM RateLimit WHERE identifier = ?",
args: [identifier] args: [identifier]
}); });
if (result.rows.length === 0) { if (result.rows.length === 0) {
// Create new record
await conn.execute({ await conn.execute({
sql: "INSERT INTO RateLimit (id, identifier, count, reset_at) VALUES (?, ?, ?, ?)", sql: "INSERT INTO RateLimit (id, identifier, count, reset_at) VALUES (?, ?, ?, ?)",
args: [uuid(), identifier, 1, resetAt.toISOString()] args: [uuid(), identifier, 1, resetAt.toISOString()]
@@ -306,9 +290,7 @@ export async function checkRateLimit(
const record = result.rows[0]; const record = result.rows[0];
const recordResetAt = new Date(record.reset_at as string); const recordResetAt = new Date(record.reset_at as string);
// Check if window has expired
if (now > recordResetAt.getTime()) { if (now > recordResetAt.getTime()) {
// Reset the record
await conn.execute({ await conn.execute({
sql: "UPDATE RateLimit SET count = 1, reset_at = ?, updated_at = datetime('now') WHERE identifier = ?", sql: "UPDATE RateLimit SET count = 1, reset_at = ?, updated_at = datetime('now') WHERE identifier = ?",
args: [resetAt.toISOString(), identifier] args: [resetAt.toISOString(), identifier]
@@ -318,12 +300,10 @@ export async function checkRateLimit(
const count = record.count as number; const count = record.count as number;
// Check if limit exceeded
if (count >= maxAttempts) { if (count >= maxAttempts) {
const remainingMs = recordResetAt.getTime() - now; const remainingMs = recordResetAt.getTime() - now;
const remainingSec = Math.ceil(remainingMs / 1000); const remainingSec = Math.ceil(remainingMs / 1000);
// Log rate limit exceeded (fire-and-forget)
if (event) { if (event) {
const { ipAddress, userAgent } = getAuditContext(event); const { ipAddress, userAgent } = getAuditContext(event);
logAuditEvent({ logAuditEvent({
@@ -337,9 +317,7 @@ export async function checkRateLimit(
ipAddress, ipAddress,
userAgent, userAgent,
success: false success: false
}).catch(() => { }).catch(() => {});
// Ignore logging errors
});
} }
throw new TRPCError({ throw new TRPCError({
@@ -348,7 +326,6 @@ export async function checkRateLimit(
}); });
} }
// Increment count
await conn.execute({ await conn.execute({
sql: "UPDATE RateLimit SET count = count + 1, updated_at = datetime('now') WHERE identifier = ?", sql: "UPDATE RateLimit SET count = count + 1, updated_at = datetime('now') WHERE identifier = ?",
args: [identifier] args: [identifier]
@@ -359,7 +336,6 @@ export async function checkRateLimit(
/** /**
* Rate limit configuration for different operations * Rate limit configuration for different operations
* Re-exported from config for backward compatibility
*/ */
export const RATE_LIMITS = CONFIG_RATE_LIMITS; export const RATE_LIMITS = CONFIG_RATE_LIMITS;
@@ -371,7 +347,6 @@ export async function rateLimitLogin(
clientIP: string, clientIP: string,
event?: H3Event event?: H3Event
): Promise<void> { ): Promise<void> {
// Rate limit by IP
await checkRateLimit( await checkRateLimit(
`login:ip:${clientIP}`, `login:ip:${clientIP}`,
RATE_LIMITS.LOGIN_IP.maxAttempts, RATE_LIMITS.LOGIN_IP.maxAttempts,
@@ -379,7 +354,6 @@ export async function rateLimitLogin(
event event
); );
// Rate limit by email
await checkRateLimit( await checkRateLimit(
`login:email:${email}`, `login:email:${email}`,
RATE_LIMITS.LOGIN_EMAIL.maxAttempts, RATE_LIMITS.LOGIN_EMAIL.maxAttempts,
@@ -433,19 +407,7 @@ export async function rateLimitEmailVerification(
); );
} }
// ========== Account Lockout ==========
/**
* Account lockout configuration
* Re-exported from config for backward compatibility
*/
export const ACCOUNT_LOCKOUT = CONFIG_ACCOUNT_LOCKOUT; export const ACCOUNT_LOCKOUT = CONFIG_ACCOUNT_LOCKOUT;
/**
* Check if an account is locked
* @param userId - User ID to check
* @returns Object with isLocked status and remaining time if locked
*/
export async function checkAccountLockout(userId: string): Promise<{ export async function checkAccountLockout(userId: string): Promise<{
isLocked: boolean; isLocked: boolean;
remainingMs?: number; remainingMs?: number;
@@ -482,7 +444,6 @@ export async function checkAccountLockout(userId: string): Promise<{
}; };
} }
// Lockout expired, clear it
await conn.execute({ await conn.execute({
sql: "UPDATE User SET locked_until = NULL, failed_attempts = 0 WHERE id = ?", sql: "UPDATE User SET locked_until = NULL, failed_attempts = 0 WHERE id = ?",
args: [userId] args: [userId]
@@ -491,11 +452,6 @@ export async function checkAccountLockout(userId: string): Promise<{
return { isLocked: false }; return { isLocked: false };
} }
/**
* Record a failed login attempt and lock account if threshold exceeded
* @param userId - User ID
* @returns Object with isLocked status and remaining time if locked
*/
export async function recordFailedLogin(userId: string): Promise<{ export async function recordFailedLogin(userId: string): Promise<{
isLocked: boolean; isLocked: boolean;
remainingMs?: number; remainingMs?: number;
@@ -504,7 +460,6 @@ export async function recordFailedLogin(userId: string): Promise<{
const { ConnectionFactory } = await import("./database"); const { ConnectionFactory } = await import("./database");
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Increment failed attempts
const result = await conn.execute({ const result = await conn.execute({
sql: `UPDATE User sql: `UPDATE User
SET failed_attempts = COALESCE(failed_attempts, 0) + 1 SET failed_attempts = COALESCE(failed_attempts, 0) + 1
@@ -515,7 +470,6 @@ export async function recordFailedLogin(userId: string): Promise<{
const failedAttempts = (result.rows[0]?.failed_attempts as number) || 0; const failedAttempts = (result.rows[0]?.failed_attempts as number) || 0;
// Check if we should lock the account
if (failedAttempts >= ACCOUNT_LOCKOUT.MAX_FAILED_ATTEMPTS) { if (failedAttempts >= ACCOUNT_LOCKOUT.MAX_FAILED_ATTEMPTS) {
const lockedUntil = new Date( const lockedUntil = new Date(
Date.now() + ACCOUNT_LOCKOUT.LOCKOUT_DURATION_MS Date.now() + ACCOUNT_LOCKOUT.LOCKOUT_DURATION_MS
@@ -541,7 +495,6 @@ export async function recordFailedLogin(userId: string): Promise<{
/** /**
* Reset failed login attempts on successful login * Reset failed login attempts on successful login
* @param userId - User ID
*/ */
export async function resetFailedAttempts(userId: string): Promise<void> { export async function resetFailedAttempts(userId: string): Promise<void> {
const { ConnectionFactory } = await import("./database"); const { ConnectionFactory } = await import("./database");
@@ -553,18 +506,10 @@ export async function resetFailedAttempts(userId: string): Promise<void> {
}); });
} }
// ========== Password Reset Token Management ==========
/**
* Password reset token configuration
* Re-exported from config for backward compatibility
*/
export const PASSWORD_RESET_CONFIG = CONFIG_PASSWORD_RESET; export const PASSWORD_RESET_CONFIG = CONFIG_PASSWORD_RESET;
/** /**
* Create a password reset token * Create a password reset token
* @param userId - User ID
* @returns The reset token and token ID
*/ */
export async function createPasswordResetToken(userId: string): Promise<{ export async function createPasswordResetToken(userId: string): Promise<{
token: string; token: string;
@@ -575,20 +520,17 @@ export async function createPasswordResetToken(userId: string): Promise<{
const { v4: uuid } = await import("uuid"); const { v4: uuid } = await import("uuid");
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Generate cryptographically secure token
const token = crypto.randomUUID(); const token = crypto.randomUUID();
const tokenId = uuid(); const tokenId = uuid();
const expiresAt = new Date( const expiresAt = new Date(
Date.now() + PASSWORD_RESET_CONFIG.TOKEN_EXPIRY_MS Date.now() + PASSWORD_RESET_CONFIG.TOKEN_EXPIRY_MS
); );
// Invalidate any existing unused tokens for this user
await conn.execute({ await conn.execute({
sql: "UPDATE PasswordResetToken SET used_at = datetime('now') WHERE user_id = ? AND used_at IS NULL", sql: "UPDATE PasswordResetToken SET used_at = datetime('now') WHERE user_id = ? AND used_at IS NULL",
args: [userId] args: [userId]
}); });
// Create new token
await conn.execute({ await conn.execute({
sql: `INSERT INTO PasswordResetToken (id, token, user_id, expires_at) sql: `INSERT INTO PasswordResetToken (id, token, user_id, expires_at)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?)`,
@@ -604,8 +546,6 @@ export async function createPasswordResetToken(userId: string): Promise<{
/** /**
* Validate and consume a password reset token * Validate and consume a password reset token
* @param token - Reset token
* @returns User ID if valid, null otherwise
*/ */
export async function validatePasswordResetToken( export async function validatePasswordResetToken(
token: string token: string
@@ -626,12 +566,10 @@ export async function validatePasswordResetToken(
const tokenRecord = result.rows[0]; const tokenRecord = result.rows[0];
// Check if already used
if (tokenRecord.used_at) { if (tokenRecord.used_at) {
return null; return null;
} }
// Check if expired
const expiresAt = new Date(tokenRecord.expires_at as string); const expiresAt = new Date(tokenRecord.expires_at as string);
if (expiresAt < new Date()) { if (expiresAt < new Date()) {
return null; return null;
@@ -645,7 +583,6 @@ export async function validatePasswordResetToken(
/** /**
* Mark a password reset token as used * Mark a password reset token as used
* @param tokenId - Token ID
*/ */
export async function markPasswordResetTokenUsed( export async function markPasswordResetTokenUsed(
tokenId: string tokenId: string
@@ -661,7 +598,6 @@ export async function markPasswordResetTokenUsed(
/** /**
* Clean up expired password reset tokens * Clean up expired password reset tokens
* Should be run periodically (e.g., via cron job)
*/ */
export async function cleanupExpiredPasswordResetTokens(): Promise<number> { export async function cleanupExpiredPasswordResetTokens(): Promise<number> {
const { ConnectionFactory } = await import("./database"); const { ConnectionFactory } = await import("./database");

View File

@@ -1,16 +1,3 @@
/**
* Comment System Type Definitions
*
* - Comment and CommentReaction models
* - WebSocket message types
* - User data structures
* - Component prop interfaces
*/
// ============================================================================
// Core Data Models
// ============================================================================
export interface Comment { export interface Comment {
id: number; id: number;
body: string; body: string;
@@ -47,10 +34,6 @@ export interface UserPublicData {
image?: string; image?: string;
} }
// ============================================================================
// WebSocket Message Types
// ============================================================================
export interface WebSocketBroadcast { export interface WebSocketBroadcast {
action: action:
| "commentCreationBroadcast" | "commentCreationBroadcast"
@@ -72,10 +55,6 @@ export interface BackupResponse {
commentParent?: number | null; commentParent?: number | null;
} }
// ============================================================================
// Privilege and Sorting Types
// ============================================================================
export type PrivilegeLevel = "admin" | "user" | "anonymous"; export type PrivilegeLevel = "admin" | "user" | "anonymous";
export type SortingMode = "newest" | "oldest" | "highest_rated" | "hot"; export type SortingMode = "newest" | "oldest" | "highest_rated" | "hot";
@@ -84,10 +63,6 @@ export type DeletionType = "user" | "admin" | "database";
export type ModificationType = "delete" | "edit"; export type ModificationType = "delete" | "edit";
// ============================================================================
// Component Props Interfaces
// ============================================================================
export interface CommentSectionWrapperProps { export interface CommentSectionWrapperProps {
privilegeLevel: PrivilegeLevel; privilegeLevel: PrivilegeLevel;
allComments: Comment[]; allComments: Comment[];

View File

@@ -1,4 +1,3 @@
// lineage User
export interface User { export interface User {
id: string; id: string;
email: string | null; email: string | null;