client->server stuff, nav on mobile improvements
This commit is contained in:
23
src/app.tsx
23
src/app.tsx
@@ -31,6 +31,12 @@ function AppLayout(props: { children: any }) {
|
|||||||
let lastScrollY = 0;
|
let lastScrollY = 0;
|
||||||
const SCROLL_THRESHOLD = 100;
|
const SCROLL_THRESHOLD = 100;
|
||||||
|
|
||||||
|
// Compute left margin reactively
|
||||||
|
const leftMargin = () => {
|
||||||
|
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||||
|
return isMobile ? 0 : leftBarSize();
|
||||||
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
const isMobile = window.innerWidth < 768; // md breakpoint
|
const isMobile = window.innerWidth < 768; // md breakpoint
|
||||||
@@ -41,7 +47,9 @@ function AppLayout(props: { children: any }) {
|
|||||||
setRightBarVisible(true);
|
setRightBarVisible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newWidth = window.innerWidth - leftBarSize() - rightBarSize();
|
// On mobile, leftbar overlays (don't subtract its size)
|
||||||
|
const leftOffset = isMobile ? 0 : leftBarSize();
|
||||||
|
const newWidth = window.innerWidth - leftOffset - rightBarSize();
|
||||||
setCenterWidth(newWidth);
|
setCenterWidth(newWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +63,10 @@ function AppLayout(props: { children: any }) {
|
|||||||
|
|
||||||
// Recalculate when bar sizes change (visibility or actual resize)
|
// Recalculate when bar sizes change (visibility or actual resize)
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const newWidth = window.innerWidth - leftBarSize() - rightBarSize();
|
const isMobile = window.innerWidth < 768;
|
||||||
|
// On mobile, leftbar overlays (don't subtract its size)
|
||||||
|
const leftOffset = isMobile ? 0 : leftBarSize();
|
||||||
|
const newWidth = window.innerWidth - leftOffset - rightBarSize();
|
||||||
setCenterWidth(newWidth);
|
setCenterWidth(newWidth);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +188,13 @@ function AppLayout(props: { children: any }) {
|
|||||||
class="bg-base relative min-h-screen rounded-t-lg shadow-2xl"
|
class="bg-base relative min-h-screen rounded-t-lg shadow-2xl"
|
||||||
style={{
|
style={{
|
||||||
width: `${centerWidth()}px`,
|
width: `${centerWidth()}px`,
|
||||||
"margin-left": `${leftBarSize()}px`
|
"margin-left": `${leftMargin()}px`
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
const isMobile = window.innerWidth < 768;
|
||||||
|
if (isMobile && leftBarVisible()) {
|
||||||
|
setLeftBarVisible(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={barsInitialized()} fallback={<TerminalSplash />}>
|
<Show when={barsInitialized()} fallback={<TerminalSplash />}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, For, Show } from "solid-js";
|
import { Component, For, Show } from "solid-js";
|
||||||
import { Typewriter } from "./Typewriter";
|
import { Typewriter } from "./Typewriter";
|
||||||
import { SkeletonText, SkeletonBox } from "./SkeletonLoader";
|
import { SkeletonText, SkeletonBox } from "./SkeletonLoader";
|
||||||
|
import { formatRelativeDate } from "~/lib/date-utils";
|
||||||
|
|
||||||
interface Commit {
|
interface Commit {
|
||||||
sha: string;
|
sha: string;
|
||||||
@@ -16,28 +17,6 @@ export const RecentCommits: Component<{
|
|||||||
title: string;
|
title: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 60) {
|
|
||||||
return `${diffMins}m ago`;
|
|
||||||
} else if (diffHours < 24) {
|
|
||||||
return `${diffHours}h ago`;
|
|
||||||
} else if (diffDays < 7) {
|
|
||||||
return `${diffDays}d ago`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<h3 class="text-subtext0 text-sm font-semibold">{props.title}</h3>
|
<h3 class="text-subtext0 text-sm font-semibold">{props.title}</h3>
|
||||||
@@ -90,7 +69,7 @@ export const RecentCommits: Component<{
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-subtext1 shrink-0 text-[10px]">
|
<span class="text-subtext1 shrink-0 text-[10px]">
|
||||||
{formatDate(commit.date)}
|
{formatRelativeDate(commit.date)}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
|
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||||
<span class="bg-surface1 shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
<span class="bg-surface1 shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Accessor, createContext, useContext, createMemo } from "solid-js";
|
import { Accessor, createContext, useContext, createMemo } from "solid-js";
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { hapticFeedback } from "~/lib/client-utils";
|
|
||||||
|
|
||||||
const BarsContext = createContext<{
|
const BarsContext = createContext<{
|
||||||
leftBarSize: Accessor<number>;
|
leftBarSize: Accessor<number>;
|
||||||
@@ -98,14 +97,11 @@ export function BarsProvider(props: { children: any }) {
|
|||||||
return barsInitialized() ? syncedBarSize() : naturalSize;
|
return barsInitialized() ? syncedBarSize() : naturalSize;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap visibility setters with haptic feedback
|
|
||||||
const setLeftBarVisible = (visible: boolean) => {
|
const setLeftBarVisible = (visible: boolean) => {
|
||||||
hapticFeedback(50);
|
|
||||||
_setLeftBarVisible(visible);
|
_setLeftBarVisible(visible);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setRightBarVisible = (visible: boolean) => {
|
const setRightBarVisible = (visible: boolean) => {
|
||||||
hapticFeedback(50);
|
|
||||||
_setRightBarVisible(visible);
|
_setRightBarVisible(visible);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ export function debounce<T extends (...args: any[]) => any>(
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a comment body meets requirements
|
* Validates that a comment body meets requirements (client-side UX only)
|
||||||
|
* Server validation is in src/server/api/schemas/validation.ts
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
|
|||||||
@@ -1,18 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* Formats current date to match SQL datetime format
|
* Client-side Date Utilities
|
||||||
* Note: Adds 4 hours to match server timezone (EST)
|
*
|
||||||
* Returns format: YYYY-MM-DD HH:MM:SS
|
* ⚠️ DEPRECATED: For new code, use server-side date utilities in src/server/date-utils.ts
|
||||||
|
*
|
||||||
|
* This function is kept for backward compatibility with existing client code.
|
||||||
|
* Server-side code should use src/server/date-utils.ts instead.
|
||||||
|
*
|
||||||
|
* Note: This previously had a hardcoded +4 hour offset which was incorrect.
|
||||||
|
* Now properly returns UTC time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current UTC date/time formatted for SQL insert
|
||||||
|
*
|
||||||
|
* @deprecated Use server-side getSQLFormattedDate from ~/server/utils instead
|
||||||
|
* @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC
|
||||||
*/
|
*/
|
||||||
export function getSQLFormattedDate(): string {
|
export function getSQLFormattedDate(): string {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setHours(date.getHours() + 4);
|
|
||||||
|
|
||||||
const year = date.getFullYear();
|
const year = date.getUTCFullYear();
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
|
||||||
const day = `${date.getDate()}`.padStart(2, "0");
|
const day = `${date.getUTCDate()}`.padStart(2, "0");
|
||||||
const hours = `${date.getHours()}`.padStart(2, "0");
|
const hours = `${date.getUTCHours()}`.padStart(2, "0");
|
||||||
const minutes = `${date.getMinutes()}`.padStart(2, "0");
|
const minutes = `${date.getUTCMinutes()}`.padStart(2, "0");
|
||||||
const seconds = `${date.getSeconds()}`.padStart(2, "0");
|
const seconds = `${date.getUTCSeconds()}`.padStart(2, "0");
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for relative display (client-side)
|
||||||
|
* e.g., "5m ago", "2h ago", "Dec 19"
|
||||||
|
*
|
||||||
|
* @param dateString SQL date string or ISO string
|
||||||
|
* @returns Formatted relative time string
|
||||||
|
*/
|
||||||
|
export function formatRelativeDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) {
|
||||||
|
return "just now";
|
||||||
|
} else if (diffMins < 60) {
|
||||||
|
return `${diffMins}m ago`;
|
||||||
|
} else if (diffHours < 24) {
|
||||||
|
return `${diffHours}h ago`;
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: diffDays > 365 ? "numeric" : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Form validation utilities
|
* Client-Side Validation Utilities (UX Only - NOT Security)
|
||||||
|
*
|
||||||
|
* ⚠️ IMPORTANT: These functions are for user experience only!
|
||||||
|
*
|
||||||
|
* Server-side validation in src/server/api/schemas/validation.ts is the
|
||||||
|
* source of truth and security boundary. These client functions provide
|
||||||
|
* instant feedback before submission but can be bypassed.
|
||||||
|
*
|
||||||
|
* Always rely on server validation for security.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate email format
|
* Validate email format (client-side UX only)
|
||||||
|
* Server validation is in src/server/api/schemas/validation.ts
|
||||||
*/
|
*/
|
||||||
export function isValidEmail(email: string): boolean {
|
export function isValidEmail(email: string): boolean {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
@@ -11,7 +20,8 @@ export function isValidEmail(email: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate password strength
|
* Validate password strength (client-side UX only)
|
||||||
|
* Server validation is in src/server/api/schemas/validation.ts
|
||||||
*/
|
*/
|
||||||
export function validatePassword(password: string): {
|
export function validatePassword(password: string): {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
@@ -41,7 +51,8 @@ export function validatePassword(password: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if two passwords match
|
* Check if two passwords match (client-side UX only)
|
||||||
|
* Server validation is in src/server/api/schemas/validation.ts
|
||||||
*/
|
*/
|
||||||
export function passwordsMatch(
|
export function passwordsMatch(
|
||||||
password: string,
|
password: string,
|
||||||
@@ -51,7 +62,8 @@ export function passwordsMatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate display name
|
* Validate display name (client-side UX only)
|
||||||
|
* Server validation is in src/server/api/schemas/validation.ts
|
||||||
*/
|
*/
|
||||||
export function isValidDisplayName(name: string): boolean {
|
export function isValidDisplayName(name: string): boolean {
|
||||||
return name.trim().length >= 1 && name.trim().length <= 50;
|
return name.trim().length >= 1 && name.trim().length <= 50;
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export default function Home() {
|
|||||||
here (github).
|
here (github).
|
||||||
</a>
|
</a>
|
||||||
</Typewriter>
|
</Typewriter>
|
||||||
|
<Typewriter speed={100} keepAlive={2000}>
|
||||||
|
Check the side bar(s) for more info and important links.
|
||||||
|
</Typewriter>
|
||||||
<div class="pt-8 text-center">
|
<div class="pt-8 text-center">
|
||||||
<div class="pb-4">Some of my recent projects:</div>
|
<div class="pb-4">Some of my recent projects:</div>
|
||||||
<div class="flex flex-col items-center gap-2 2xl:flex-row 2xl:items-start 2xl:justify-center">
|
<div class="flex flex-col items-center gap-2 2xl:flex-row 2xl:items-start 2xl:justify-center">
|
||||||
@@ -89,7 +92,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Life and Lineage */}
|
{/* Life and Lineage */}
|
||||||
<div class="border-surface0 flex w-full max-w-3/4 flex-col gap-2 rounded-md border-2 p-4 text-center 2xl:mr-4">
|
<div class="border-surface0 flex w-full max-w-5/6 flex-col gap-2 rounded-md border-2 p-4 text-center 2xl:mr-4">
|
||||||
<div>My mobile game:</div>
|
<div>My mobile game:</div>
|
||||||
<a
|
<a
|
||||||
class="text-blue hover-underline-animation mx-auto w-fit"
|
class="text-blue hover-underline-animation mx-auto w-fit"
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
|||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
import { setCookie, getCookie } from "vinxi/http";
|
import { setCookie, getCookie } from "vinxi/http";
|
||||||
import type { User } from "~/types/user";
|
import type { User } from "~/types/user";
|
||||||
|
import {
|
||||||
|
emailSchema,
|
||||||
|
passwordSchema,
|
||||||
|
registrationSchema,
|
||||||
|
loginSchema,
|
||||||
|
passwordResetSchema
|
||||||
|
} from "~/server/api/schemas/validation";
|
||||||
|
|
||||||
// Helper to create JWT token
|
// Helper to create JWT token
|
||||||
async function createJWT(
|
async function createJWT(
|
||||||
@@ -239,7 +246,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
emailLogin: publicProcedure
|
emailLogin: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: emailSchema,
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
rememberMe: z.boolean().optional()
|
rememberMe: z.boolean().optional()
|
||||||
})
|
})
|
||||||
@@ -317,7 +324,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
emailVerification: publicProcedure
|
emailVerification: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: emailSchema,
|
||||||
token: z.string()
|
token: z.string()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -360,22 +367,9 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Email/password registration
|
// Email/password registration
|
||||||
emailRegistration: publicProcedure
|
emailRegistration: publicProcedure
|
||||||
.input(
|
.input(registrationSchema)
|
||||||
z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(8),
|
|
||||||
passwordConfirmation: z.string().min(8)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email, password, passwordConfirmation } = input;
|
const { email, password } = input;
|
||||||
|
|
||||||
if (password !== passwordConfirmation) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "passwordMismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
@@ -411,13 +405,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Email/password login
|
// Email/password login
|
||||||
emailPasswordLogin: publicProcedure
|
emailPasswordLogin: publicProcedure
|
||||||
.input(
|
.input(loginSchema)
|
||||||
z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string(),
|
|
||||||
rememberMe: z.boolean().optional()
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email, password, rememberMe } = input;
|
const { email, password, rememberMe } = input;
|
||||||
|
|
||||||
@@ -477,7 +465,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
requestEmailLinkLogin: publicProcedure
|
requestEmailLinkLogin: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: emailSchema,
|
||||||
rememberMe: z.boolean().optional()
|
rememberMe: z.boolean().optional()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -582,7 +570,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Request password reset
|
// Request password reset
|
||||||
requestPasswordReset: publicProcedure
|
requestPasswordReset: publicProcedure
|
||||||
.input(z.object({ email: z.string().email() }))
|
.input(z.object({ email: emailSchema }))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
@@ -681,22 +669,9 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Reset password with token
|
// Reset password with token
|
||||||
resetPassword: publicProcedure
|
resetPassword: publicProcedure
|
||||||
.input(
|
.input(passwordResetSchema)
|
||||||
z.object({
|
|
||||||
token: z.string(),
|
|
||||||
newPassword: z.string().min(8),
|
|
||||||
newPasswordConfirmation: z.string().min(8)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { token, newPassword, newPasswordConfirmation } = input;
|
const { token, newPassword } = input;
|
||||||
|
|
||||||
if (newPassword !== newPasswordConfirmation) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Password Mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify JWT token
|
// Verify JWT token
|
||||||
@@ -743,7 +718,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Resend email verification
|
// Resend email verification
|
||||||
resendEmailVerification: publicProcedure
|
resendEmailVerification: publicProcedure
|
||||||
.input(z.object({ email: z.string().email() }))
|
.input(z.object({ email: emailSchema }))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
@@ -852,4 +827,3 @@ export const authRouter = createTRPCRouter({
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
import { setCookie } from "vinxi/http";
|
import { setCookie } from "vinxi/http";
|
||||||
import type { User } from "~/types/user";
|
import type { User } from "~/types/user";
|
||||||
import { toUserProfile } from "~/types/user";
|
import { toUserProfile } from "~/types/user";
|
||||||
|
import {
|
||||||
|
updateEmailSchema,
|
||||||
|
updateDisplayNameSchema,
|
||||||
|
passwordChangeSchema,
|
||||||
|
passwordSetSchema,
|
||||||
|
deleteAccountSchema
|
||||||
|
} from "~/server/api/schemas/validation";
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
// Get current user profile
|
// Get current user profile
|
||||||
@@ -43,7 +50,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Update email
|
// Update email
|
||||||
updateEmail: publicProcedure
|
updateEmail: publicProcedure
|
||||||
.input(z.object({ email: z.string().email() }))
|
.input(updateEmailSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = await getUserID(ctx.event.nativeEvent);
|
const userId = await getUserID(ctx.event.nativeEvent);
|
||||||
|
|
||||||
@@ -80,7 +87,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Update display name
|
// Update display name
|
||||||
updateDisplayName: publicProcedure
|
updateDisplayName: publicProcedure
|
||||||
.input(z.object({ displayName: z.string().min(1).max(50) }))
|
.input(updateDisplayNameSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = await getUserID(ctx.event.nativeEvent);
|
const userId = await getUserID(ctx.event.nativeEvent);
|
||||||
|
|
||||||
@@ -111,7 +118,9 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Update profile image
|
// Update profile image
|
||||||
updateProfileImage: publicProcedure
|
updateProfileImage: publicProcedure
|
||||||
.input(z.object({ imageUrl: z.string() }))
|
.input(
|
||||||
|
z.object({ imageUrl: z.string().url().optional().or(z.literal("")) })
|
||||||
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = await getUserID(ctx.event.nativeEvent);
|
const userId = await getUserID(ctx.event.nativeEvent);
|
||||||
|
|
||||||
@@ -142,13 +151,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Change password (requires old password)
|
// Change password (requires old password)
|
||||||
changePassword: publicProcedure
|
changePassword: publicProcedure
|
||||||
.input(
|
.input(passwordChangeSchema)
|
||||||
z.object({
|
|
||||||
oldPassword: z.string(),
|
|
||||||
newPassword: z.string().min(8),
|
|
||||||
newPasswordConfirmation: z.string().min(8)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = await getUserID(ctx.event.nativeEvent);
|
const userId = await getUserID(ctx.event.nativeEvent);
|
||||||
|
|
||||||
@@ -159,14 +162,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { oldPassword, newPassword, newPasswordConfirmation } = input;
|
const { oldPassword, newPassword } = input;
|
||||||
|
|
||||||
if (newPassword !== newPasswordConfirmation) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Password Mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const res = await conn.execute({
|
const res = await conn.execute({
|
||||||
@@ -224,12 +220,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Set password (for OAuth users who don't have password)
|
// Set password (for OAuth users who don't have password)
|
||||||
setPassword: publicProcedure
|
setPassword: publicProcedure
|
||||||
.input(
|
.input(passwordSetSchema)
|
||||||
z.object({
|
|
||||||
newPassword: z.string().min(8),
|
|
||||||
newPasswordConfirmation: z.string().min(8)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = await getUserID(ctx.event.nativeEvent);
|
const userId = await getUserID(ctx.event.nativeEvent);
|
||||||
|
|
||||||
@@ -240,14 +231,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { newPassword, newPasswordConfirmation } = input;
|
const { password } = input;
|
||||||
|
|
||||||
if (newPassword !== newPasswordConfirmation) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Password Mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const res = await conn.execute({
|
const res = await conn.execute({
|
||||||
@@ -272,7 +256,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set password
|
// Set password
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(password);
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
|
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
|
||||||
args: [passwordHash, userId]
|
args: [passwordHash, userId]
|
||||||
@@ -293,7 +277,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Delete account (anonymize data)
|
// Delete account (anonymize data)
|
||||||
deleteAccount: publicProcedure
|
deleteAccount: publicProcedure
|
||||||
.input(z.object({ password: z.string() }))
|
.input(deleteAccountSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const userId = await getUserID(ctx.event.nativeEvent);
|
const userId = await getUserID(ctx.event.nativeEvent);
|
||||||
|
|
||||||
|
|||||||
153
src/server/api/schemas/validation.ts
Normal file
153
src/server/api/schemas/validation.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation Schemas for tRPC Procedures
|
||||||
|
*
|
||||||
|
* These schemas are the source of truth for server-side validation.
|
||||||
|
* Client-side validation (src/lib/validation.ts) is optional for UX only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Base Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email validation schema
|
||||||
|
* - Must be valid email format
|
||||||
|
* - Min 3 chars, max 255 chars
|
||||||
|
* - Trimmed and lowercased automatically
|
||||||
|
*/
|
||||||
|
export const emailSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.email("Invalid email address")
|
||||||
|
.min(3, "Email too short")
|
||||||
|
.max(255, "Email too long");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password validation schema
|
||||||
|
* - Minimum 8 characters
|
||||||
|
* - Maximum 128 characters
|
||||||
|
* - Can add additional complexity requirements if needed
|
||||||
|
*/
|
||||||
|
export const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters")
|
||||||
|
.max(128, "Password too long");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name validation schema
|
||||||
|
* - Minimum 1 character (after trim)
|
||||||
|
* - Maximum 50 characters
|
||||||
|
*/
|
||||||
|
export const displayNameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Display name is required")
|
||||||
|
.max(50, "Display name too long");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comment body validation schema
|
||||||
|
* - Minimum 1 character (after trim)
|
||||||
|
* - Maximum 10,000 characters
|
||||||
|
*/
|
||||||
|
export const commentBodySchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Comment cannot be empty")
|
||||||
|
.max(10000, "Comment too long (max 10,000 characters)");
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Composed Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email/password login schema
|
||||||
|
*/
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
rememberMe: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email/password registration schema with password confirmation
|
||||||
|
*/
|
||||||
|
export const registrationSchema = z
|
||||||
|
.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
passwordConfirmation: passwordSchema,
|
||||||
|
displayName: displayNameSchema.optional()
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirmation, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["passwordConfirmation"]
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password change schema (requires old password)
|
||||||
|
*/
|
||||||
|
export const passwordChangeSchema = z
|
||||||
|
.object({
|
||||||
|
oldPassword: passwordSchema,
|
||||||
|
newPassword: passwordSchema,
|
||||||
|
newPasswordConfirmation: passwordSchema
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
||||||
|
message: "New passwords do not match",
|
||||||
|
path: ["newPasswordConfirmation"]
|
||||||
|
})
|
||||||
|
.refine((data) => data.oldPassword !== data.newPassword, {
|
||||||
|
message: "New password must be different from old password",
|
||||||
|
path: ["newPassword"]
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password reset schema (no old password required)
|
||||||
|
*/
|
||||||
|
export const passwordResetSchema = z
|
||||||
|
.object({
|
||||||
|
token: z.string(),
|
||||||
|
newPassword: passwordSchema,
|
||||||
|
newPasswordConfirmation: passwordSchema
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["newPasswordConfirmation"]
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password set schema (for OAuth users setting password first time)
|
||||||
|
*/
|
||||||
|
export const passwordSetSchema = z
|
||||||
|
.object({
|
||||||
|
password: passwordSchema,
|
||||||
|
passwordConfirmation: passwordSchema
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirmation, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["passwordConfirmation"]
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update email schema
|
||||||
|
*/
|
||||||
|
export const updateEmailSchema = z.object({
|
||||||
|
email: emailSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update display name schema
|
||||||
|
*/
|
||||||
|
export const updateDisplayNameSchema = z.object({
|
||||||
|
displayName: displayNameSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account deletion schema
|
||||||
|
*/
|
||||||
|
export const deleteAccountSchema = z.object({
|
||||||
|
password: passwordSchema
|
||||||
|
});
|
||||||
124
src/server/date-utils.ts
Normal file
124
src/server/date-utils.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Server-side Date/Time Utilities
|
||||||
|
*
|
||||||
|
* All dates are handled in UTC to avoid timezone issues.
|
||||||
|
* Database should store dates in UTC format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current UTC date/time formatted for SQL insert
|
||||||
|
*
|
||||||
|
* @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC
|
||||||
|
* @example
|
||||||
|
* getSQLFormattedDate() // "2024-12-19 15:30:45"
|
||||||
|
*/
|
||||||
|
export function getSQLFormattedDate(): string {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const year = now.getUTCFullYear();
|
||||||
|
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getUTCDate()).padStart(2, "0");
|
||||||
|
const hours = String(now.getUTCHours()).padStart(2, "0");
|
||||||
|
const minutes = String(now.getUTCMinutes()).padStart(2, "0");
|
||||||
|
const seconds = String(now.getUTCSeconds()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date object for SQL insert
|
||||||
|
*
|
||||||
|
* @param date Date object to format
|
||||||
|
* @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC
|
||||||
|
* @example
|
||||||
|
* formatDateForSQL(new Date()) // "2024-12-19 15:30:45"
|
||||||
|
*/
|
||||||
|
export function formatDateForSQL(date: Date): string {
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||||
|
const hours = String(date.getUTCHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
||||||
|
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a client-provided date string to Date object
|
||||||
|
*
|
||||||
|
* @param dateString ISO 8601 date string from client
|
||||||
|
* @returns Date object or null if invalid
|
||||||
|
* @example
|
||||||
|
* parseClientDate("2024-12-19T15:30:45.000Z") // Date object
|
||||||
|
* parseClientDate("invalid") // null
|
||||||
|
*/
|
||||||
|
export function parseClientDate(dateString: string): Date | null {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return isNaN(date.getTime()) ? null : date;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display with relative time
|
||||||
|
* Handles "X minutes ago", "X hours ago", etc.
|
||||||
|
*
|
||||||
|
* @param dateString SQL date string or ISO string
|
||||||
|
* @returns Formatted relative time string
|
||||||
|
* @example
|
||||||
|
* formatRelativeDate("2024-12-19 15:00:00") // "30m ago" (if now is 15:30)
|
||||||
|
*/
|
||||||
|
export function formatRelativeDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) {
|
||||||
|
return "just now";
|
||||||
|
} else if (diffMins < 60) {
|
||||||
|
return `${diffMins}m ago`;
|
||||||
|
} else if (diffHours < 24) {
|
||||||
|
return `${diffHours}h ago`;
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
} else {
|
||||||
|
// For older dates, return formatted date
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: diffDays > 365 ? "numeric" : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display
|
||||||
|
*
|
||||||
|
* @param dateString SQL date string or ISO string
|
||||||
|
* @returns Formatted date string (e.g., "Dec 19, 2024")
|
||||||
|
* @example
|
||||||
|
* formatDateForDisplay("2024-12-19 15:30:45") // "Dec 19, 2024"
|
||||||
|
*/
|
||||||
|
export function formatDateForDisplay(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get timestamp in milliseconds (UTC)
|
||||||
|
*
|
||||||
|
* @returns Current timestamp in ms
|
||||||
|
*/
|
||||||
|
export function getCurrentTimestamp(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
@@ -17,3 +17,12 @@ export {
|
|||||||
export { hashPassword, checkPassword } from "./password";
|
export { hashPassword, checkPassword } from "./password";
|
||||||
|
|
||||||
export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email";
|
export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getSQLFormattedDate,
|
||||||
|
formatDateForSQL,
|
||||||
|
parseClientDate,
|
||||||
|
formatRelativeDate,
|
||||||
|
formatDateForDisplay,
|
||||||
|
getCurrentTimestamp
|
||||||
|
} from "./date-utils";
|
||||||
|
|||||||
Reference in New Issue
Block a user