@castlekit/castle 0.1.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/drizzle.config.ts +7 -0
- package/install.sh +20 -1
- package/next.config.ts +1 -0
- package/package.json +35 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +217 -0
- package/src/app/api/openclaw/chat/route.ts +283 -0
- package/src/app/api/openclaw/chat/search/route.ts +150 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +210 -0
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +385 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +99 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +59 -25
- package/src/app/settings/page.tsx +300 -0
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +328 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +168 -0
- package/src/components/chat/message-list.tsx +666 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +444 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +110 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +125 -0
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/index.ts +652 -0
- package/src/lib/db/queries.ts +1144 -0
- package/src/lib/db/schema.ts +164 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +753 -0
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +122 -100
- package/src/lib/hooks/use-search.ts +114 -0
- package/src/lib/hooks/use-session-stats.ts +60 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +202 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
|
@@ -4,17 +4,18 @@ import { cn } from "@/lib/utils";
|
|
|
4
4
|
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
5
|
size?: "sm" | "md" | "lg";
|
|
6
6
|
status?: "online" | "offline" | "busy" | "away";
|
|
7
|
+
statusPulse?: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
|
10
|
-
({ className, size = "md", status, children, ...props }, ref) => {
|
|
11
|
+
({ className, size = "md", status, statusPulse, children, ...props }, ref) => {
|
|
11
12
|
return (
|
|
12
|
-
<div className="relative inline-block">
|
|
13
|
+
<div className="relative inline-block shrink-0">
|
|
13
14
|
<div
|
|
14
15
|
className={cn(
|
|
15
|
-
"relative flex shrink-0 overflow-hidden rounded-[
|
|
16
|
+
"relative flex shrink-0 overflow-hidden rounded-[4px] bg-surface border border-border",
|
|
16
17
|
{
|
|
17
|
-
"h-
|
|
18
|
+
"h-9 w-9": size === "sm",
|
|
18
19
|
"h-10 w-10": size === "md",
|
|
19
20
|
"h-12 w-12": size === "lg",
|
|
20
21
|
},
|
|
@@ -28,18 +29,19 @@ const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
|
|
28
29
|
{status && (
|
|
29
30
|
<span
|
|
30
31
|
className={cn(
|
|
31
|
-
"absolute
|
|
32
|
+
"absolute block rounded-full ring-2 ring-background",
|
|
32
33
|
{
|
|
33
|
-
"h-2.5 w-2.5": size === "sm",
|
|
34
|
-
"h-3 w-3": size === "md",
|
|
35
|
-
"h-3.5 w-3.5": size === "lg",
|
|
34
|
+
"h-2.5 w-2.5 -bottom-0.5 -right-0.5": size === "sm",
|
|
35
|
+
"h-3 w-3 -bottom-0.5 -right-0.5": size === "md",
|
|
36
|
+
"h-3.5 w-3.5 -bottom-0.5 -right-0.5": size === "lg",
|
|
36
37
|
},
|
|
37
38
|
{
|
|
38
39
|
"bg-success": status === "online",
|
|
39
40
|
"bg-foreground-muted": status === "offline",
|
|
40
41
|
"bg-error": status === "busy",
|
|
41
42
|
"bg-warning": status === "away",
|
|
42
|
-
}
|
|
43
|
+
},
|
|
44
|
+
statusPulse && "animate-pulse"
|
|
43
45
|
)}
|
|
44
46
|
/>
|
|
45
47
|
)}
|
|
@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
|
|
|
7
7
|
export interface DialogProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
8
|
open?: boolean;
|
|
9
9
|
onClose?: () => void;
|
|
10
|
+
onOpenChange?: (open: boolean) => void;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const Dialog = forwardRef<HTMLDivElement, DialogProps>(
|
|
13
|
-
({ className, open = false, onClose, children, ...props }, ref) => {
|
|
14
|
+
({ className, open = false, onClose, onOpenChange, children, ...props }, ref) => {
|
|
14
15
|
if (!open) return null;
|
|
15
16
|
|
|
17
|
+
const handleClose = () => {
|
|
18
|
+
onClose?.();
|
|
19
|
+
onOpenChange?.(false);
|
|
20
|
+
};
|
|
21
|
+
|
|
16
22
|
return (
|
|
17
23
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
18
24
|
<div
|
|
19
25
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
|
20
|
-
onClick={
|
|
26
|
+
onClick={handleClose}
|
|
21
27
|
/>
|
|
22
28
|
<div
|
|
23
29
|
className={cn(
|
|
@@ -27,9 +33,9 @@ const Dialog = forwardRef<HTMLDivElement, DialogProps>(
|
|
|
27
33
|
ref={ref}
|
|
28
34
|
{...props}
|
|
29
35
|
>
|
|
30
|
-
{onClose && (
|
|
36
|
+
{(onClose || onOpenChange) && (
|
|
31
37
|
<button
|
|
32
|
-
onClick={
|
|
38
|
+
onClick={handleClose}
|
|
33
39
|
className="absolute right-4 top-4 p-1 rounded-[var(--radius-sm)] text-foreground-muted hover:text-foreground hover:bg-surface-hover transition-colors"
|
|
34
40
|
>
|
|
35
41
|
<X className="h-4 w-4" />
|
|
@@ -9,16 +9,20 @@ export interface TooltipProps {
|
|
|
9
9
|
content: string;
|
|
10
10
|
side?: "top" | "right" | "bottom" | "left";
|
|
11
11
|
className?: string;
|
|
12
|
+
/** Delay in ms before showing the tooltip (default 0) */
|
|
13
|
+
delay?: number;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
function Tooltip({
|
|
15
17
|
children,
|
|
16
18
|
content,
|
|
17
19
|
side = "right",
|
|
18
|
-
className
|
|
20
|
+
className,
|
|
21
|
+
delay = 0,
|
|
19
22
|
}: TooltipProps) {
|
|
20
23
|
const [isHovered, setIsHovered] = useState(false);
|
|
21
24
|
const [mounted, setMounted] = useState(false);
|
|
25
|
+
const delayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
22
26
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
23
27
|
const [isPositioned, setIsPositioned] = useState(false);
|
|
24
28
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
@@ -26,6 +30,10 @@ function Tooltip({
|
|
|
26
30
|
|
|
27
31
|
useEffect(() => {
|
|
28
32
|
setMounted(true);
|
|
33
|
+
return () => {
|
|
34
|
+
// Clean up delay timer on unmount to prevent setState on unmounted component
|
|
35
|
+
if (delayRef.current) clearTimeout(delayRef.current);
|
|
36
|
+
};
|
|
29
37
|
}, []);
|
|
30
38
|
|
|
31
39
|
const updatePosition = useCallback(() => {
|
|
@@ -83,8 +91,17 @@ function Tooltip({
|
|
|
83
91
|
<>
|
|
84
92
|
<div
|
|
85
93
|
ref={triggerRef}
|
|
86
|
-
onMouseEnter={() =>
|
|
87
|
-
|
|
94
|
+
onMouseEnter={() => {
|
|
95
|
+
if (delay > 0) {
|
|
96
|
+
delayRef.current = setTimeout(() => setIsHovered(true), delay);
|
|
97
|
+
} else {
|
|
98
|
+
setIsHovered(true);
|
|
99
|
+
}
|
|
100
|
+
}}
|
|
101
|
+
onMouseLeave={() => {
|
|
102
|
+
if (delayRef.current) { clearTimeout(delayRef.current); delayRef.current = null; }
|
|
103
|
+
setIsHovered(false);
|
|
104
|
+
}}
|
|
88
105
|
className={className}
|
|
89
106
|
>
|
|
90
107
|
{children}
|
|
@@ -110,26 +127,26 @@ function Tooltip({
|
|
|
110
127
|
side === "bottom" && (isPositioned ? "translate-y-0" : "-translate-y-2")
|
|
111
128
|
)}
|
|
112
129
|
>
|
|
113
|
-
<div className="relative bg-
|
|
130
|
+
<div className="relative bg-[#1a1a1a] text-white text-sm font-medium px-3 py-1.5 rounded-[4px] whitespace-nowrap shadow-xl shadow-black/25">
|
|
114
131
|
{content}
|
|
115
132
|
{side === "right" && (
|
|
116
133
|
<div className="absolute -left-[7px] top-1/2 -translate-y-1/2">
|
|
117
|
-
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-
|
|
134
|
+
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-[#1a1a1a]" />
|
|
118
135
|
</div>
|
|
119
136
|
)}
|
|
120
137
|
{side === "left" && (
|
|
121
138
|
<div className="absolute -right-[7px] top-1/2 -translate-y-1/2">
|
|
122
|
-
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[8px] border-l-
|
|
139
|
+
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[8px] border-l-[#1a1a1a]" />
|
|
123
140
|
</div>
|
|
124
141
|
)}
|
|
125
142
|
{side === "top" && (
|
|
126
143
|
<div className="absolute -bottom-[7px] left-1/2 -translate-x-1/2">
|
|
127
|
-
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-
|
|
144
|
+
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-[#1a1a1a]" />
|
|
128
145
|
</div>
|
|
129
146
|
)}
|
|
130
147
|
{side === "bottom" && (
|
|
131
148
|
<div className="absolute -top-[7px] left-1/2 -translate-x-1/2">
|
|
132
|
-
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-
|
|
149
|
+
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-[#1a1a1a]" />
|
|
133
150
|
</div>
|
|
134
151
|
)}
|
|
135
152
|
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, memo } from "react";
|
|
4
|
+
import twemoji from "@twemoji/api";
|
|
5
|
+
|
|
6
|
+
interface TwemojiTextProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps children and replaces native Unicode emojis with Twemoji SVG images.
|
|
13
|
+
* Uses twemoji.parse() on the rendered DOM node after mount/update.
|
|
14
|
+
*/
|
|
15
|
+
export const TwemojiText = memo(function TwemojiText({
|
|
16
|
+
children,
|
|
17
|
+
className,
|
|
18
|
+
}: TwemojiTextProps) {
|
|
19
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (ref.current) {
|
|
23
|
+
twemoji.parse(ref.current, {
|
|
24
|
+
folder: "svg",
|
|
25
|
+
ext: ".svg",
|
|
26
|
+
// Use jsDelivr CDN for the SVG assets
|
|
27
|
+
base: "https://cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets/",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<span ref={ref} className={className}>
|
|
34
|
+
{children}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
});
|
package/src/lib/api-security.ts
CHANGED
|
@@ -1,5 +1,130 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Localhost guard — reject requests from non-local IPs
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
const LOCALHOST_IPS = new Set([
|
|
8
|
+
"127.0.0.1",
|
|
9
|
+
"::1",
|
|
10
|
+
"::ffff:127.0.0.1",
|
|
11
|
+
"localhost",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reject requests that don't originate from localhost.
|
|
16
|
+
* Uses x-forwarded-for (if behind a proxy) or falls back to the host header.
|
|
17
|
+
* Returns a 403 response if the request is from a non-local IP, or null if OK.
|
|
18
|
+
*/
|
|
19
|
+
export function checkLocalhost(request: NextRequest): NextResponse | null {
|
|
20
|
+
// If running in development, skip the check
|
|
21
|
+
if (process.env.NODE_ENV === "development") return null;
|
|
22
|
+
|
|
23
|
+
const forwarded = request.headers.get("x-forwarded-for");
|
|
24
|
+
const host = request.headers.get("host") || "";
|
|
25
|
+
|
|
26
|
+
// Check x-forwarded-for first (proxy scenario)
|
|
27
|
+
if (forwarded) {
|
|
28
|
+
const clientIp = forwarded.split(",")[0].trim();
|
|
29
|
+
if (!LOCALHOST_IPS.has(clientIp)) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: "Forbidden — Castle is only accessible from localhost" },
|
|
32
|
+
{ status: 403 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check host header — reject if it's not a localhost variant
|
|
38
|
+
const hostname = host.split(":")[0];
|
|
39
|
+
if (hostname && !LOCALHOST_IPS.has(hostname)) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: "Forbidden — Castle is only accessible from localhost" },
|
|
42
|
+
{ status: 403 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Rate limiting — in-memory sliding window
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
interface RateLimitEntry {
|
|
54
|
+
timestamps: number[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rateLimitStore = new Map<string, RateLimitEntry>();
|
|
58
|
+
|
|
59
|
+
// Clean up stale entries every 5 minutes
|
|
60
|
+
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
61
|
+
function ensureCleanup() {
|
|
62
|
+
if (cleanupTimer) return;
|
|
63
|
+
cleanupTimer = setInterval(() => {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const [key, entry] of rateLimitStore.entries()) {
|
|
66
|
+
entry.timestamps = entry.timestamps.filter((t) => now - t < 60_000);
|
|
67
|
+
if (entry.timestamps.length === 0) rateLimitStore.delete(key);
|
|
68
|
+
}
|
|
69
|
+
}, 5 * 60_000);
|
|
70
|
+
// Don't prevent process exit
|
|
71
|
+
if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
72
|
+
cleanupTimer.unref();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Simple in-memory rate limiter using a sliding window.
|
|
78
|
+
*
|
|
79
|
+
* @param key — Unique key for the rate limit bucket (e.g. "chat:send" or IP-based)
|
|
80
|
+
* @param limit — Max requests allowed within the window
|
|
81
|
+
* @param windowMs — Window duration in milliseconds (default: 60 seconds)
|
|
82
|
+
* @returns 429 response if rate limit exceeded, or null if OK
|
|
83
|
+
*/
|
|
84
|
+
export function checkRateLimit(
|
|
85
|
+
key: string,
|
|
86
|
+
limit: number,
|
|
87
|
+
windowMs = 60_000
|
|
88
|
+
): NextResponse | null {
|
|
89
|
+
ensureCleanup();
|
|
90
|
+
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
let entry = rateLimitStore.get(key);
|
|
93
|
+
|
|
94
|
+
if (!entry) {
|
|
95
|
+
entry = { timestamps: [] };
|
|
96
|
+
rateLimitStore.set(key, entry);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Drop timestamps outside the window
|
|
100
|
+
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
|
|
101
|
+
|
|
102
|
+
if (entry.timestamps.length >= limit) {
|
|
103
|
+
return NextResponse.json(
|
|
104
|
+
{ error: "Too many requests — please slow down" },
|
|
105
|
+
{ status: 429 }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
entry.timestamps.push(now);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Helper to build a rate-limit key from the request IP + route.
|
|
115
|
+
*/
|
|
116
|
+
export function rateLimitKey(request: NextRequest, route: string): string {
|
|
117
|
+
const ip =
|
|
118
|
+
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
|
|
119
|
+
request.headers.get("x-real-ip") ||
|
|
120
|
+
"local";
|
|
121
|
+
return `${ip}:${route}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// CSRF protection
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
3
128
|
/**
|
|
4
129
|
* Verify that a mutating API request originated from the Castle UI itself,
|
|
5
130
|
* not from a cross-origin attacker (CSRF protection).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Shared date formatting — consistent across the app
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Formats:
|
|
6
|
+
// formatDate(ts) → "9 February 2026"
|
|
7
|
+
// formatDateTime(ts) → "9 February 2026 at 2:24 am"
|
|
8
|
+
// formatTime(ts) → "2:24 am"
|
|
9
|
+
// formatTimeAgo(ts) → "3 hours ago", "2 days ago", etc.
|
|
10
|
+
// formatDateShort(ts) → "9 Feb" (for compact UI like search results)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "9 February 2026"
|
|
14
|
+
*/
|
|
15
|
+
export function formatDate(timestamp: number): string {
|
|
16
|
+
return new Date(timestamp).toLocaleDateString("en-GB", {
|
|
17
|
+
day: "numeric",
|
|
18
|
+
month: "long",
|
|
19
|
+
year: "numeric",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* "9 February 2026 at 2:24 am"
|
|
25
|
+
*/
|
|
26
|
+
export function formatDateTime(timestamp: number): string {
|
|
27
|
+
const date = new Date(timestamp);
|
|
28
|
+
const datePart = date.toLocaleDateString("en-GB", {
|
|
29
|
+
day: "numeric",
|
|
30
|
+
month: "long",
|
|
31
|
+
year: "numeric",
|
|
32
|
+
});
|
|
33
|
+
const timePart = date.toLocaleTimeString("en-GB", {
|
|
34
|
+
hour: "numeric",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
hour12: true,
|
|
37
|
+
});
|
|
38
|
+
return `${datePart} at ${timePart}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* "2:24 am"
|
|
43
|
+
*/
|
|
44
|
+
export function formatTime(timestamp: number): string {
|
|
45
|
+
return new Date(timestamp).toLocaleTimeString("en-GB", {
|
|
46
|
+
hour: "numeric",
|
|
47
|
+
minute: "2-digit",
|
|
48
|
+
hour12: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* "3 hours ago", "2 days ago", "just now", etc.
|
|
54
|
+
*/
|
|
55
|
+
export function formatTimeAgo(timestamp: number): string {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const diff = now - timestamp;
|
|
58
|
+
|
|
59
|
+
const seconds = Math.floor(diff / 1000);
|
|
60
|
+
if (seconds < 60) return "just now";
|
|
61
|
+
|
|
62
|
+
const minutes = Math.floor(seconds / 60);
|
|
63
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
64
|
+
|
|
65
|
+
const hours = Math.floor(minutes / 60);
|
|
66
|
+
if (hours < 24) return `${hours}h ago`;
|
|
67
|
+
|
|
68
|
+
const days = Math.floor(hours / 24);
|
|
69
|
+
if (days < 7) return `${days}d ago`;
|
|
70
|
+
|
|
71
|
+
const weeks = Math.floor(days / 7);
|
|
72
|
+
if (weeks < 5) return `${weeks}w ago`;
|
|
73
|
+
|
|
74
|
+
const months = Math.floor(days / 30);
|
|
75
|
+
if (months < 12) return `${months}mo ago`;
|
|
76
|
+
|
|
77
|
+
const years = Math.floor(days / 365);
|
|
78
|
+
return `${years}y ago`;
|
|
79
|
+
}
|