removed excess comments
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
4
src/env/client.ts
vendored
@@ -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
4
src/env/server.ts
vendored
@@ -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);
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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 (­) into long words to enable manual hyphenation
|
* Inserts soft hyphens (­) 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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.") {
|
||||||
|
|||||||
@@ -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()}`);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = ?
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// lineage User
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user