feat(frontend): add shared client libraries and UI primitives
Introduce API helpers, types, hooks, design-system components, and the authenticated app shell.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { http } from "@/lib/api";
|
||||
import type { User } from "@/lib/types";
|
||||
|
||||
export function useLiveUser(): User | null {
|
||||
const user = useAuth((s) => s.user);
|
||||
const setUser = useAuth((s) => s.setUser);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => http.get<{ user: User }>("/api/auth/me"),
|
||||
enabled: !!user,
|
||||
refetchInterval: user ? 60_000 : false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fresh = data?.user;
|
||||
if (!fresh || !user) return;
|
||||
if (
|
||||
fresh.id === user.id &&
|
||||
fresh.name === user.name &&
|
||||
fresh.email === user.email &&
|
||||
fresh.role === user.role &&
|
||||
fresh.xp === user.xp &&
|
||||
fresh.hearts === user.hearts &&
|
||||
fresh.streakCurrent === user.streakCurrent &&
|
||||
fresh.streakLongest === user.streakLongest &&
|
||||
fresh.isLocked === user.isLocked &&
|
||||
fresh.isActive === user.isActive &&
|
||||
fresh.avatarVariant === user.avatarVariant &&
|
||||
fresh.avatarPalette === user.avatarPalette &&
|
||||
fresh.avatarPhoto === user.avatarPhoto
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setUser(fresh);
|
||||
}, [data, user, setUser]);
|
||||
|
||||
return data?.user ?? user;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth-store";
|
||||
import { getWsUrl } from "@/lib/api";
|
||||
import { toast } from "@/components/providers";
|
||||
|
||||
interface WSPayload {
|
||||
type?: string;
|
||||
item?: {
|
||||
type?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
category?: string;
|
||||
route?: string;
|
||||
};
|
||||
from?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Connects to the realtime gateway, toasts live notifications and bumps unread counts. */
|
||||
export function useRealtimeGateway() {
|
||||
const accessToken = useAuth((s) => s.accessToken);
|
||||
const user = useAuth((s) => s.user);
|
||||
const client = useQueryClient();
|
||||
const lastEvent = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
let ws: WebSocket | null = null;
|
||||
let retry = 0;
|
||||
let closed = false;
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
// Prefer subprotocol when we have an in-memory access token; otherwise rely on httpOnly cookie.
|
||||
ws = accessToken
|
||||
? new WebSocket(getWsUrl(), ["bearer", accessToken])
|
||||
: new WebSocket(getWsUrl());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
retry = 0;
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const p: WSPayload = JSON.parse(ev.data);
|
||||
if (p.type === "notification" && p.item) {
|
||||
const n = p.item;
|
||||
const stamp = Date.now();
|
||||
const dedupeKey = `${n.type}:${n.title}:${n.body}`;
|
||||
if (stamp - (lastEvent.current[dedupeKey] ?? 0) < 2000) return;
|
||||
lastEvent.current[dedupeKey] = stamp;
|
||||
|
||||
toast.info(`${n.title}${n.body ? ` — ${n.body}` : ""}`, { duration: 5000 });
|
||||
client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
client.invalidateQueries({ queryKey: ["me"] });
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (closed) return;
|
||||
retry += 1;
|
||||
const t = Math.min(1000 * 2 ** retry, 15000);
|
||||
setTimeout(connect, t);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
closed = true;
|
||||
ws?.close();
|
||||
};
|
||||
}, [accessToken, user, client]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user