Compare commits

..

6 Commits

Author SHA1 Message Date
igm
44a480179b Merge remote-tracking branch 'origin/master'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-11 14:19:58 +08:00
igm
628b01f4d8 Add type-safe navigation helpers and public pages
- Create gotoLogin() helper for login redirects with search params
- Add /terms and /privacy public routes with Tailwind typography
- Update auth-guard to allow unauthenticated access to public pages
- Fix resolve() usage across navigation components using as const pattern
- Fix eslint-disable-next-line placement for svelte/no-navigation-without-resolve
- Document SvelteKit resolve() patterns in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:19:33 +08:00
igm
76a5e40900 Merge branch 'gitea-action' 2026-01-11 12:34:17 +08:00
igm
b1d07626f3 Add packages/common for shared utilities
Create new @reviq/common package with environment-agnostic utilities:
- Date formatting: formatDate, formatDateTime, formatLongDate,
  formatRelativeDate, formatRelativeTime
- User utilities: getUserInitials, formatRole

Consolidate date formatting from publisher-dashboard into shared package.
All utilities include comprehensive test coverage with bun:test.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:34:10 +08:00
igm
99539bbdcb Update Bun version to 1.3.5
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:27:52 +08:00
igm
eedd664db8 Add Gitea Action CI workflow
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:22:51 +08:00
45 changed files with 929 additions and 371 deletions

34
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,34 @@
name: CI
on:
push:
branches:
- master
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.5"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Build
run: bun run build
- name: Test
run: bun run test

View File

@@ -21,3 +21,50 @@ macOS uses BSD sed which differs from GNU sed:
- GNU sed (Linux): `sed -i 's/old/new/g' file`
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
## SvelteKit resolve() Usage
Use `resolve()` from `$app/paths` for type-safe navigation. The patterns are:
### Static routes - use resolve() directly
```svelte
href={resolve("/auth/login")}
href={resolve("/dashboard")}
```
### Dynamic routes - use two-argument form
```svelte
href={resolve("/dashboard/[slug]", { slug: orgSlug })}
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
```
### Login redirects - use gotoLogin helper
For redirecting to login with a return URL, use the helper from `$lib/utils/navigation`:
```typescript
import { gotoLogin } from "$lib/utils/navigation";
gotoLogin(page.url.pathname);
```
This helper uses resolve() internally and handles the query string correctly.
### Navigation arrays - use `as const` with route patterns
For type-safe navigation arrays, define routes as literal strings with `as const`:
```typescript
const navItems = [
{ route: "/dashboard/[slug]/settings", icon: Settings, label: "General" },
{ route: "/dashboard/[slug]/settings/members", icon: Users, label: "Members" },
] as const;
```
Then use resolve with params:
```svelte
{#each navItems as item (item.route)}
<a href={resolve(item.route, { slug })}>
{/each}
```
### Runtime strings - skip resolve, use eslint-disable
When paths are fully dynamic (e.g., server-provided redirects), skip resolve:
```typescript
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(redirectUrl);
```

View File

@@ -15,6 +15,7 @@
"@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3",
@@ -36,6 +37,7 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5",

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
/* Geist Sans - Modern, clean typeface */
@font-face {

View File

@@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js";
@@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean {
>
{#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
active

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null);
let isDeleting = $state(false);
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string | null): string {
if (!date) {
return "Never";
}
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function openRename(passkey: Passkey) {
selectedPasskey = passkey;
renameDialogOpen = true;

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { gotoLogin } from "$lib/utils/navigation";
interface Props {
children: Snippet;
@@ -12,29 +11,29 @@ interface Props {
let { children }: Props = $props();
// Check if current path is an auth page (doesn't require login)
const isAuthPage = $derived(page.url.pathname.startsWith("/auth"));
// Check if current path is a public page (doesn't require login)
const isPublicPage = $derived(
page.url.pathname.startsWith("/auth") ||
page.url.pathname === "/terms" ||
page.url.pathname === "/privacy",
);
// Fetch user to check if logged in (only for non-auth pages)
// Fetch user to check if logged in (only for protected pages)
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
enabled: !isAuthPage,
enabled: !isPublicPage,
retry: false,
}));
// Redirect to login if not authenticated on non-auth pages
// Redirect to login if not authenticated on protected pages
$effect(() => {
if (!isAuthPage && userQuery.error) {
goto(
resolve(
`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any,
),
);
if (!isPublicPage && userQuery.error) {
gotoLogin(page.url.pathname);
}
});
</script>
{#if isAuthPage || userQuery.data || userQuery.isPending}
{#if isPublicPage || userQuery.data || userQuery.isPending}
{@render children()}
{/if}

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { cn } from "$lib/utils.js";
interface Props {
@@ -27,8 +26,8 @@ const filters = [
<div class="divide-y divide-border/50">
{#each filters as filter (filter.label)}
<a
href={resolve(filter.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={filter.href}
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
>
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { cn } from "$lib/utils.js";
import {
@@ -33,14 +32,15 @@ const activeTab = $derived(
($page.url.searchParams.get("tab") as TabId) || defaultTab,
);
function handleTabChange(tabId: string) {
function handleTabChange(tabId: string): void {
const url = new URL($page.url);
if (tabId === defaultTab) {
url.searchParams.delete("tab");
} else {
url.searchParams.set("tab", tabId);
}
goto(resolve(url.toString() as any), { replaceState: true, noScroll: true });
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.toString(), { replaceState: true, noScroll: true });
}
</script>

View File

@@ -6,7 +6,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout";
@@ -94,8 +93,8 @@ function isActive(href: string): boolean {
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active
@@ -113,8 +112,8 @@ function isActive(href: string): boolean {
<div class="hidden space-y-1 lg:block">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
@@ -24,31 +25,15 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
function handleNavClick() {
function handleNavClick(): void {
open = false;
}
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
@@ -98,8 +83,8 @@ const navItems = [
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
onclick={handleNavClick}
class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
@@ -20,27 +21,11 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
@@ -90,8 +75,8 @@ const navItems = [
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive

View File

@@ -74,8 +74,8 @@ const navItems = $derived.by(() => {
? $page.url.pathname === item.href
: $page.url.pathname === item.href ||
$page.url.pathname.startsWith(item.href + "/")}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive
@@ -163,7 +163,7 @@ const navItems = $derived.by(() => {
{#if currentSlug}
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
<a
href={resolve(`/dashboard/${currentSlug}/settings`)}
href={resolve("/dashboard/[slug]/settings", { slug: currentSlug })}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isSettingsActive

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
@@ -32,28 +33,11 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const initials = $derived(getUserInitials(user));
// Nav items depend on whether we're in an org context
const navItems = $derived.by(() => {
if (currentSlug) {
// In org context - org-specific navigation
return [
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
{
@@ -68,7 +52,6 @@ const navItems = $derived.by(() => {
},
];
}
// Outside org context - general navigation
return [
{ icon: "home", href: "/", label: "Home" },
{ icon: "building", href: "/dashboard", label: "Organizations" },
@@ -77,16 +60,17 @@ const navItems = $derived.by(() => {
const queryClient = useQueryClient();
function handleNavClick() {
function handleNavClick(): void {
open = false;
}
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
open = false;
goto(resolve("/auth/login"));
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto("/auth/login");
} catch (error) {
console.error("Failed to sign out:", error);
}
@@ -123,8 +107,8 @@ async function handleSignOut() {
{@const isActive =
$page.url.pathname === item.href ||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
onclick={handleNavClick}
class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",

View File

@@ -20,7 +20,7 @@ const orgsQuery = createQuery(() => ({
const orgs = $derived(orgsQuery.data ?? []);
function handleOrgSelect(slug: string) {
goto(resolve(`/dashboard/${slug}` as any));
goto(resolve(`/dashboard/${slug}`));
}
</script>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
@@ -19,30 +20,13 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
// Clear all cached queries
queryClient.clear();
goto(resolve("/auth/login"));
} catch (error) {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { Building2, Globe, Settings, Users } from "@lucide/svelte";
import { Globe, Settings, Users } from "@lucide/svelte";
import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
@@ -16,36 +16,37 @@ let { title, children }: Props = $props();
// Get org context from parent layout
const orgContext = getContext<{ slug: string }>("orgContext");
const slug = $derived(orgContext?.slug);
const slug = $derived(orgContext?.slug ?? "");
// Settings navigation items
const navItems = $derived.by(() => [
// Settings navigation items with route patterns for type-safe resolve()
const navItems = [
{
href: `/dashboard/${slug}/settings`,
route: "/dashboard/[slug]/settings",
icon: Settings,
label: "General",
description: "Organization name, logo, and preferences",
},
{
href: `/dashboard/${slug}/settings/members`,
route: "/dashboard/[slug]/settings/members",
icon: Users,
label: "Members",
description: "Manage team members and invitations",
},
{
href: `/dashboard/${slug}/settings/sites`,
route: "/dashboard/[slug]/settings/sites",
icon: Globe,
label: "Sites",
description: "Connected websites and domains",
},
]);
] as const;
// Determine active item
const activeHref = $derived($page.url.pathname);
function isActive(href: string): boolean {
function isActive(route: (typeof navItems)[number]["route"]): boolean {
const href = resolve(route, { slug });
// Exact match for base settings path
if (href === `/dashboard/${slug}/settings`) {
if (route === "/dashboard/[slug]/settings") {
return activeHref === href;
}
// Prefix match for sub-pages
@@ -59,10 +60,10 @@ function isActive(href: string): boolean {
<nav class="w-full shrink-0 lg:w-64">
<!-- Mobile: horizontal scroll -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
{#each navItems as item (item.route)}
{@const active = isActive(item.route)}
<a
href={resolve(item.href as any)}
href={resolve(item.route, { slug })}
class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active
@@ -78,10 +79,10 @@ function isActive(href: string): boolean {
<!-- Desktop: vertical list -->
<div class="hidden space-y-1 lg:block">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
{#each navItems as item (item.route)}
{@const active = isActive(item.route)}
<a
href={resolve(item.href as any)}
href={resolve(item.route, { slug })}
class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active

View File

@@ -48,8 +48,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
</script>
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- Button receives href as prop, callers must use resolve() */
let {
let {
class: className,
variant = "default",
size = "default",
@@ -67,7 +66,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
href={disabled ? undefined : href/* eslint-disable-line svelte/no-navigation-without-resolve */}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}

View File

@@ -1,31 +0,0 @@
/**
* Date formatting utilities for consistent display across the app
*/
/**
* Format a date for display in tables and lists
* Example: "Jan 15, 2024"
*/
export function formatDate(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Format a date with time for detailed views
* Example: "Jan 15, 2024, 3:30 PM"
*/
export function formatDateTime(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}

View File

@@ -0,0 +1,26 @@
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
type SearchParams = Record<string, string>;
/**
* Build a query string from an object.
*/
function buildSearchParams(params: SearchParams): string {
const searchParams = new URLSearchParams(params);
const str = searchParams.toString();
return str ? `?${str}` : "";
}
/**
* Navigate to /auth/login with a redirect parameter.
* This is the primary use case for navigation with search params.
*
* Note: eslint-disable is required because the lint rule doesn't recognize
* resolve() inside a template literal, even though it's used correctly.
*/
export function gotoLogin(redirect: string): ReturnType<typeof goto> {
const url = `${resolve("/auth/login")}${buildSearchParams({ redirect })}`;
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used above
return goto(url);
}

View File

@@ -17,11 +17,12 @@ const orgsQuery = createQuery(() => ({
$effect(() => {
if (orgsQuery.error) {
// Not authenticated, redirect to login
goto(resolve(`/auth/login?redirect=${encodeURIComponent("/")}` as any));
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent("/")}`);
} else if (orgsQuery.data) {
if (orgsQuery.data.length > 0) {
// Redirect to first org's dashboard
goto(resolve(`/dashboard/${orgsQuery.data[0].slug}` as any), {
goto(resolve("/dashboard/[slug]", { slug: orgsQuery.data[0].slug }), {
replaceState: true,
});
} else {

View File

@@ -8,6 +8,7 @@ import {
Plus,
Trash2,
} from "@lucide/svelte";
import { formatDate, formatRelativeDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
@@ -59,33 +60,6 @@ let isCreating = $state(false);
let newlyCreatedToken = $state<string | null>(null);
let tokenCopied = $state(false);
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const diffDays = Math.floor(
(Date.now() - new Date(date).getTime()) / 86400000,
);
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(date);
}
async function handleCreateToken(e: Event) {
e.preventDefault();
if (!newTokenName.trim() || isCreating) {
@@ -261,9 +235,9 @@ async function handleDelete() {
<div>
<p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)}
Created {formatRelativeDate(token.createdAt)}
{#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)}
· Last used {formatRelativeDate(token.lastUsedAt)}
{:else}
· Never used
{/if}

View File

@@ -8,9 +8,9 @@ import {
Star,
Tablet,
} from "@lucide/svelte";
import { formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
@@ -54,31 +54,6 @@ function formatLocation(device: {
return parts.length > 0 ? parts.join(", ") : "Unknown location";
}
function formatRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function getDeviceIcon(name: string) {
const nameLower = name.toLowerCase();
if (

View File

@@ -10,6 +10,7 @@ import {
User,
XCircle,
} from "@lucide/svelte";
import { formatLongDate, formatRole } from "@reviq/common";
import {
createMutation,
createQuery,
@@ -48,12 +49,10 @@ const acceptMutation = createMutation(() => ({
mutationFn: () => api.me.invites.accept({ inviteId }),
onSuccess: () => {
toast.success("You've joined the organization!");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
queryClient.invalidateQueries({ queryKey: ["orgs"] });
// Redirect to the org dashboard
if (inviteQuery.data) {
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any));
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}`));
} else {
goto(resolve("/dashboard"));
}
@@ -70,7 +69,6 @@ const declineMutation = createMutation(() => ({
mutationFn: () => api.me.invites.decline({ inviteId }),
onSuccess: () => {
toast.success("Invitation declined");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
goto(resolve("/dashboard"));
},
@@ -81,24 +79,6 @@ const declineMutation = createMutation(() => ({
},
}));
/**
* Format role for display
*/
function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
/**
* Format date for display
*/
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
/**
* Check if invite is expiring soon (within 3 days)
*/
@@ -187,7 +167,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
</div>
<div>
<p class="text-sm font-medium">Sent on</p>
<p class="text-sm text-muted-foreground">{formatDate(new Date(invite.createdAt))}</p>
<p class="text-sm text-muted-foreground">{formatLongDate(invite.createdAt)}</p>
</div>
</div>
<div class="flex items-center gap-3">
@@ -197,7 +177,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<div>
<p class="text-sm font-medium">Expires on</p>
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
{formatDate(new Date(invite.expiresAt))}
{formatLongDate(invite.expiresAt)}
</p>
</div>
</div>
@@ -207,7 +187,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Alert>
<Clock class="h-4 w-4" />
<AlertDescription>
This invitation will expire soon. Accept it before {formatDate(new Date(invite.expiresAt))} to join the organization.
This invitation will expire soon. Accept it before {formatLongDate(invite.expiresAt)} to join the organization.
</AlertDescription>
</Alert>
{/if}

View File

@@ -10,6 +10,7 @@ import {
Star,
Tablet,
} from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
@@ -56,36 +57,6 @@ function formatLocation(session: {
return parts.length > 0 ? parts.join(", ") : "Unknown location";
}
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function parseUserAgent(userAgent: string): {
browser: string;
os: string;

View File

@@ -6,6 +6,7 @@ import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js";
import { gotoLogin } from "$lib/utils/navigation";
interface Props {
children: Snippet;
@@ -26,11 +27,7 @@ $effect(() => {
goto(resolve("/dashboard"));
}
if (userQuery.error) {
goto(
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
gotoLogin(window.location.pathname);
}
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
import { formatDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { resolve } from "$app/paths";
@@ -22,7 +23,6 @@ import {
TableHeader,
TableRow,
} from "$lib/components/ui/table/index.js";
import { formatDate } from "$lib/utils/format-date.js";
/**
* Admin Organizations list page

View File

@@ -9,6 +9,7 @@ import {
Plus,
Trash2,
} from "@lucide/svelte";
import { formatDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
@@ -37,7 +38,6 @@ import {
TableHeader,
TableRow,
} from "$lib/components/ui/table";
import { formatDate } from "$lib/utils/format-date.js";
/**
* Admin organization details page

View File

@@ -81,9 +81,9 @@ let { children }: Props = $props();
<!-- Footer -->
<p class="text-center text-xs text-muted-foreground">
By continuing, you agree to our
<a href={resolve("/terms" as any)} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
<a href={resolve("/terms")} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
and
<a href={resolve("/privacy" as any)} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
<a href={resolve("/privacy")} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
</p>
</div>
</div>

View File

@@ -59,7 +59,8 @@ const statusQuery = createQuery(() => ({
$effect(() => {
if (statusQuery.data?.status === "completed") {
clearLoginFlowState();
goto(resolve((statusQuery.data.redirectTo || "/") as any));
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(statusQuery.data.redirectTo || "/");
}
});

View File

@@ -6,8 +6,8 @@ import {
Loader2,
Mail,
} from "@lucide/svelte";
import { formatRelativeDate, formatRole } from "@reviq/common";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout";
@@ -19,6 +19,7 @@ import {
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { gotoLogin } from "$lib/utils/navigation";
/**
* Dashboard page - lists all organizations the user is a member of
@@ -40,48 +41,9 @@ const invitesQuery = createQuery(() => ({
// Redirect to login on auth error
$effect(() => {
if (orgsQuery.error) {
goto(
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
gotoLogin(window.location.pathname);
}
});
/**
* Format date to relative or absolute string
*/
function formatDate(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return "Today";
}
if (days === 1) {
return "Yesterday";
}
if (days < 7) {
return `${days} days ago`;
}
if (days < 30) {
return `${Math.floor(days / 7)} weeks ago`;
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
/**
* Format role for display
*/
function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
</script>
<svelte:head>
@@ -101,7 +63,7 @@ function formatRole(role: string): string {
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each invitesQuery.data as invite (invite.id)}
<a
href={resolve(`/account/org-invites/${invite.id}`)}
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
class="group block"
>
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
@@ -133,7 +95,7 @@ function formatRole(role: string): string {
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
From {invite.invitedBy} &middot; {formatDate(new Date(invite.createdAt))}
From {invite.invitedBy} &middot; {formatRelativeDate(invite.createdAt)}
</p>
</CardContent>
</Card>
@@ -186,7 +148,7 @@ function formatRole(role: string): string {
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each orgsQuery.data as org (org.id)}
<a
href={resolve(`/dashboard/${org.slug}`)}
href={resolve("/dashboard/[slug]", { slug: org.slug })}
class="group block transition-transform hover:scale-[1.02]"
>
<Card class="h-full transition-colors group-hover:border-primary/50">
@@ -216,7 +178,7 @@ function formatRole(role: string): string {
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
Created {formatDate(new Date(org.createdAt))}
Created {formatRelativeDate(org.createdAt)}
</p>
</CardContent>
</Card>

View File

@@ -46,9 +46,8 @@ async function acceptInvite(): Promise<void> {
if (!isAuthenticated) {
// Redirect to login with return URL
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
goto(
resolve(`/auth/login?redirect=${encodeURIComponent(returnUrl)}` as any),
);
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent(returnUrl)}`);
return;
}

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { resolve } from "$app/paths";
</script>
<svelte:head>
<title>Privacy Policy | Publisher Dashboard</title>
</svelte:head>
<div class="mx-auto max-w-3xl px-6 py-16">
<article class="prose prose-neutral dark:prose-invert">
<h1>Privacy Policy</h1>
<p class="lead">Last updated: January 2025</p>
<h2>1. Information We Collect</h2>
<p>
We collect information you provide directly to us, such as your email address,
name, and organization details when you create an account.
</p>
<h2>2. How We Use Your Information</h2>
<p>
We use the information we collect to provide, maintain, and improve our services,
and to communicate with you about your account and updates.
</p>
<h2>3. Data Security</h2>
<p>
We implement appropriate security measures to protect your personal information
against unauthorized access, alteration, or destruction.
</p>
<h2>4. Data Retention</h2>
<p>
We retain your information for as long as your account is active or as needed
to provide you services and comply with legal obligations.
</p>
<h2>5. Contact</h2>
<p>
If you have any questions about this Privacy Policy, please contact us.
</p>
</article>
<div class="mt-12">
<a
href={resolve("/auth/login")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Back to login
</a>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { resolve } from "$app/paths";
</script>
<svelte:head>
<title>Terms of Service | Publisher Dashboard</title>
</svelte:head>
<div class="mx-auto max-w-3xl px-6 py-16">
<article class="prose prose-neutral dark:prose-invert">
<h1>Terms of Service</h1>
<p class="lead">Last updated: January 2025</p>
<h2>1. Acceptance of Terms</h2>
<p>
By accessing and using the Publisher Dashboard, you agree to be bound by these Terms of Service
and all applicable laws and regulations.
</p>
<h2>2. Use of Service</h2>
<p>
You agree to use the service only for lawful purposes and in accordance with these Terms.
You are responsible for maintaining the confidentiality of your account credentials.
</p>
<h2>3. Privacy</h2>
<p>
Your use of the service is also governed by our
<a href={resolve("/privacy")}>Privacy Policy</a>.
</p>
<h2>4. Modifications</h2>
<p>
We reserve the right to modify these terms at any time. Continued use of the service
constitutes acceptance of any modifications.
</p>
<h2>5. Contact</h2>
<p>
If you have any questions about these Terms, please contact us.
</p>
</article>
<div class="mt-12">
<a
href={resolve("/auth/login")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Back to login
</a>
</div>
</div>

View File

@@ -77,6 +77,7 @@
"@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3",
@@ -98,6 +99,7 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5",
@@ -129,6 +131,17 @@
"typescript": "catalog:",
},
},
"packages/common": {
"name": "@reviq/common",
"version": "0.0.1",
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/db": {
"name": "@reviq/db",
"version": "0.0.1",
@@ -406,6 +419,8 @@
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
"@reviq/common": ["@reviq/common@workspace:packages/common"],
"@reviq/db": ["@reviq/db@workspace:packages/db"],
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
@@ -518,6 +533,8 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
@@ -946,7 +963,7 @@
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
@@ -1154,6 +1171,8 @@
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],

View File

@@ -32,5 +32,5 @@
"tslib": "^2.8.1",
"typescript": "^5.7.2"
},
"packageManager": "bun@1.1.42"
"packageManager": "bun@1.3.5"
}

134
packages/common/README.md Normal file
View File

@@ -0,0 +1,134 @@
# @reviq/common
Shared utilities for all RevIQ applications. This package contains environment-agnostic code that works in browsers, Node.js, Bun, and other JavaScript runtimes.
## Installation
This package is used internally within the monorepo:
```bash
# Add to your app's package.json
"dependencies": {
"@reviq/common": "workspace:*"
}
```
## Date Formatting
Consistent date formatting utilities for displaying dates across the application.
### Functions
#### `formatDate(date)`
Format a date for display in tables and lists.
```typescript
import { formatDate } from "@reviq/common";
formatDate("2024-01-15"); // "Jan 15, 2024"
formatDate(new Date()); // "Jan 15, 2024"
```
#### `formatDateTime(date)`
Format a date with time for detailed views.
```typescript
import { formatDateTime } from "@reviq/common";
formatDateTime("2024-01-15T15:30:00"); // "Jan 15, 2024, 3:30 PM"
```
#### `formatLongDate(date)`
Format a date in long form.
```typescript
import { formatLongDate } from "@reviq/common";
formatLongDate("2024-01-15"); // "January 15, 2024"
```
#### `formatRelativeDate(date, options?)`
Format a date as a relative time string.
```typescript
import { formatRelativeDate } from "@reviq/common";
formatRelativeDate("2024-01-15"); // "Today" (if today is Jan 15)
formatRelativeDate("2024-01-14"); // "Yesterday"
formatRelativeDate("2024-01-10"); // "5 days ago"
formatRelativeDate("2024-01-01"); // "2 weeks ago"
formatRelativeDate("2023-06-15"); // "Jun 15, 2023"
// With custom reference date
formatRelativeDate("2024-01-10", { now: new Date("2024-01-15") });
```
#### `formatRelativeTime(date, options?)`
Same as `formatRelativeDate`, but returns "Never" for null/undefined values. Useful for "last used" timestamps.
```typescript
import { formatRelativeTime } from "@reviq/common";
formatRelativeTime("2024-01-15"); // "Today"
formatRelativeTime(null); // "Never"
formatRelativeTime(undefined); // "Never"
```
## User Utilities
Helper functions for working with user data.
### Functions
#### `getUserInitials(user)`
Generate initials from a user's display name or email.
```typescript
import { getUserInitials } from "@reviq/common";
getUserInitials({ displayName: "John Doe", email: "john@example.com" }); // "JD"
getUserInitials({ displayName: "John", email: "john@example.com" }); // "JO"
getUserInitials({ email: "john@example.com" }); // "JO"
getUserInitials(null); // "??"
```
#### `formatRole(role)`
Format a role string for display (capitalizes first letter).
```typescript
import { formatRole } from "@reviq/common";
formatRole("admin"); // "Admin"
formatRole("member"); // "Member"
formatRole("owner"); // "Owner"
```
## Development
```bash
# Run tests
bun test
# Build
bun run build
# Type check
bun run typecheck
```
## Adding New Utilities
When adding new utilities to this package:
1. Create a new file in `src/` (e.g., `src/my-utility.ts`)
2. Add comprehensive tests in `src/my-utility.test.ts`
3. Export from `src/index.ts`
4. Run `bun test` to verify tests pass
5. Run `bun run build` to compile

View File

@@ -0,0 +1,15 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
ignores: ["**/*.test.ts"],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -0,0 +1,26 @@
{
"name": "@reviq/common",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache",
"test": "bun test"
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import {
formatDate,
formatDateTime,
formatLongDate,
formatRelativeDate,
formatRelativeTime,
} from "./format-date.js";
describe("formatDate", () => {
test("formats a Date object", () => {
const date = new Date("2024-01-15T12:00:00Z");
expect(formatDate(date)).toBe("Jan 15, 2024");
});
test("formats a date string", () => {
expect(formatDate("2024-01-15T12:00:00Z")).toBe("Jan 15, 2024");
});
test("formats different months correctly", () => {
expect(formatDate("2024-06-01T12:00:00Z")).toBe("Jun 1, 2024");
expect(formatDate("2024-12-25T12:00:00Z")).toBe("Dec 25, 2024");
});
});
describe("formatDateTime", () => {
test("formats date with time", () => {
const date = new Date("2024-01-15T15:30:00Z");
const result = formatDateTime(date);
// Contains date parts
expect(result).toContain("Jan");
expect(result).toContain("15");
expect(result).toContain("2024");
// Contains time (format may vary by locale)
expect(result).toMatch(/\d{1,2}:\d{2}/);
});
test("formats a date string with time", () => {
const result = formatDateTime("2024-01-15T08:00:00Z");
expect(result).toContain("Jan");
expect(result).toContain("15");
expect(result).toContain("2024");
});
});
describe("formatLongDate", () => {
test("formats date in long form", () => {
const date = new Date("2024-01-15T12:00:00Z");
expect(formatLongDate(date)).toBe("January 15, 2024");
});
test("formats a date string in long form", () => {
expect(formatLongDate("2024-06-01T12:00:00Z")).toBe("June 1, 2024");
});
test("formats December correctly", () => {
expect(formatLongDate("2024-12-25T12:00:00Z")).toBe("December 25, 2024");
});
});
describe("formatRelativeDate", () => {
const now = new Date("2024-01-15T12:00:00Z");
test("returns 'Today' for same day", () => {
const today = new Date("2024-01-15T08:00:00Z");
expect(formatRelativeDate(today, { now })).toBe("Today");
});
test("returns 'Yesterday' for previous day", () => {
const yesterday = new Date("2024-01-14T12:00:00Z");
expect(formatRelativeDate(yesterday, { now })).toBe("Yesterday");
});
test("returns 'X days ago' for 2-6 days", () => {
expect(formatRelativeDate("2024-01-13T12:00:00Z", { now })).toBe(
"2 days ago",
);
expect(formatRelativeDate("2024-01-12T12:00:00Z", { now })).toBe(
"3 days ago",
);
expect(formatRelativeDate("2024-01-09T12:00:00Z", { now })).toBe(
"6 days ago",
);
});
test("returns '1 week ago' for exactly 7 days", () => {
const oneWeekAgo = new Date("2024-01-08T12:00:00Z");
expect(formatRelativeDate(oneWeekAgo, { now })).toBe("1 week ago");
});
test("returns 'X weeks ago' for 2-4 weeks", () => {
expect(formatRelativeDate("2024-01-01T12:00:00Z", { now })).toBe(
"2 weeks ago",
);
expect(formatRelativeDate("2023-12-25T12:00:00Z", { now })).toBe(
"3 weeks ago",
);
});
test("returns formatted date for older dates in same year", () => {
// Use a "now" later in the year to test same-year formatting
const laterNow = new Date("2024-06-15T12:00:00Z");
const result = formatRelativeDate("2024-01-15T12:00:00Z", {
now: laterNow,
});
expect(result).toBe("Jan 15");
});
test("returns formatted date with year for different year", () => {
const result = formatRelativeDate("2023-06-15T12:00:00Z", { now });
expect(result).toBe("Jun 15, 2023");
});
test("accepts string input", () => {
expect(formatRelativeDate("2024-01-15T08:00:00Z", { now })).toBe("Today");
});
});
describe("formatRelativeTime", () => {
const now = new Date("2024-01-15T12:00:00Z");
test("returns 'Never' for null", () => {
expect(formatRelativeTime(null)).toBe("Never");
});
test("returns 'Never' for undefined", () => {
expect(formatRelativeTime(undefined)).toBe("Never");
});
test("returns relative date for valid input", () => {
expect(formatRelativeTime("2024-01-15T08:00:00Z", { now })).toBe("Today");
expect(formatRelativeTime("2024-01-14T12:00:00Z", { now })).toBe(
"Yesterday",
);
});
test("handles Date objects", () => {
const date = new Date("2024-01-13T12:00:00Z");
expect(formatRelativeTime(date, { now })).toBe("2 days ago");
});
});

View File

@@ -0,0 +1,128 @@
/**
* Date formatting utilities for consistent display across the app.
* Works in all JavaScript environments (browser, Node.js, Bun, etc.)
*/
type DateInput = string | Date;
/**
* Safely convert a date input to a Date object.
*/
function toDate(date: DateInput): Date {
return typeof date === "string" ? new Date(date) : date;
}
/**
* Calculate the difference in days between two dates.
*/
function daysDiff(from: Date, to: Date): number {
const diffMs = to.getTime() - from.getTime();
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
/**
* Format a date for display in tables and lists.
* @example formatDate("2024-01-15") // "Jan 15, 2024"
*/
export function formatDate(date: DateInput): string {
const d = toDate(date);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Format a date with time for detailed views.
* @example formatDateTime("2024-01-15T15:30:00") // "Jan 15, 2024, 3:30 PM"
*/
export function formatDateTime(date: DateInput): string {
const d = toDate(date);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
/**
* Format a date in long form.
* @example formatLongDate("2024-01-15") // "January 15, 2024"
*/
export function formatLongDate(date: DateInput): string {
const d = toDate(date);
return d.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
/**
* Options for relative date formatting.
*/
export interface FormatRelativeDateOptions {
/**
* Reference date to compare against. Defaults to current date.
*/
now?: Date;
}
/**
* Format a date as a relative time string.
* @example
* formatRelativeDate("2024-01-15") // "Today" (if today is Jan 15)
* formatRelativeDate("2024-01-14") // "Yesterday" (if today is Jan 15)
* formatRelativeDate("2024-01-10") // "5 days ago" (if today is Jan 15)
* formatRelativeDate("2024-01-01") // "2 weeks ago" (if today is Jan 15)
* formatRelativeDate("2023-06-15") // "Jun 15, 2023" (older dates)
*/
export function formatRelativeDate(
date: DateInput,
options?: FormatRelativeDateOptions,
): string {
const d = toDate(date);
const now = options?.now ?? new Date();
const diffDays = daysDiff(d, now);
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${String(diffDays)} days ago`;
}
if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return weeks === 1 ? "1 week ago" : `${String(weeks)} weeks ago`;
}
// For older dates, show the actual date
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
/**
* Format a date as a relative time string, with "Never" for null values.
* Useful for displaying "last used" timestamps.
* @example
* formatRelativeTime("2024-01-15") // "Today"
* formatRelativeTime(null) // "Never"
*/
export function formatRelativeTime(
date: DateInput | null | undefined,
options?: FormatRelativeDateOptions,
): string {
if (date === null || date === undefined) {
return "Never";
}
return formatRelativeDate(date, options);
}

View File

@@ -0,0 +1,9 @@
export {
type FormatRelativeDateOptions,
formatDate,
formatDateTime,
formatLongDate,
formatRelativeDate,
formatRelativeTime,
} from "./format-date.js";
export { formatRole, getUserInitials } from "./user.js";

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import { formatRole, getUserInitials } from "./user.js";
describe("getUserInitials", () => {
test("returns '??' for null", () => {
expect(getUserInitials(null)).toBe("??");
});
test("returns '??' for undefined", () => {
expect(getUserInitials(undefined)).toBe("??");
});
test("returns initials from display name with two words", () => {
expect(
getUserInitials({ displayName: "John Doe", email: "john@example.com" }),
).toBe("JD");
});
test("returns initials from display name with multiple words", () => {
expect(
getUserInitials({
displayName: "John Michael Doe",
email: "john@example.com",
}),
).toBe("JD");
});
test("returns first two characters for single word display name", () => {
expect(
getUserInitials({ displayName: "John", email: "john@example.com" }),
).toBe("JO");
});
test("returns uppercase initials", () => {
expect(
getUserInitials({
displayName: "john doe",
email: "john@example.com",
}),
).toBe("JD");
});
test("falls back to email when no display name", () => {
expect(getUserInitials({ email: "john@example.com" })).toBe("JO");
});
test("handles null display name", () => {
expect(
getUserInitials({ displayName: null, email: "alice@example.com" }),
).toBe("AL");
});
test("handles empty display name", () => {
expect(getUserInitials({ displayName: "", email: "bob@example.com" })).toBe(
"BO",
);
});
});
describe("formatRole", () => {
test("capitalizes 'admin'", () => {
expect(formatRole("admin")).toBe("Admin");
});
test("capitalizes 'member'", () => {
expect(formatRole("member")).toBe("Member");
});
test("capitalizes 'owner'", () => {
expect(formatRole("owner")).toBe("Owner");
});
test("handles already capitalized roles", () => {
expect(formatRole("Admin")).toBe("Admin");
});
test("handles single character", () => {
expect(formatRole("a")).toBe("A");
});
test("handles empty string", () => {
expect(formatRole("")).toBe("");
});
});

View File

@@ -0,0 +1,51 @@
/**
* User-related utility functions
*/
interface UserLike {
displayName?: string | null;
email: string;
}
/**
* Generate initials from a user's display name or email.
* - For display names with 2+ words: first and last initials (e.g., "John Doe" -> "JD")
* - For single word names: first 2 characters (e.g., "John" -> "JO")
* - Falls back to first 2 characters of email if no display name
* - Returns "??" if user is null/undefined
*
* @example
* getUserInitials({ displayName: "John Doe", email: "john@example.com" }) // "JD"
* getUserInitials({ displayName: "John", email: "john@example.com" }) // "JO"
* getUserInitials({ email: "john@example.com" }) // "JO"
* getUserInitials(null) // "??"
*/
export function getUserInitials(user: UserLike | null | undefined): string {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
const firstPart = parts[0];
const lastPart = parts[parts.length - 1];
if (parts.length >= 2 && firstPart && lastPart) {
return (firstPart.charAt(0) + lastPart.charAt(0)).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
}
/**
* Format a role string for display (capitalizes first letter).
*
* @example
* formatRole("admin") // "Admin"
* formatRole("member") // "Member"
* formatRole("owner") // "Owner"
*/
export function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}