305 lines
9.6 KiB
TypeScript
305 lines
9.6 KiB
TypeScript
/**
|
|
* Browser-side BFF client for the social features: posts, bookmarks,
|
|
* collections, public profiles.
|
|
*
|
|
* All requests run with `credentials: "include"` so the auth cookie set
|
|
* by /auth/login is sent along automatically. Errors come back as
|
|
* `SocialError` instances carrying the HTTP status code and the message
|
|
* from the BFF envelope.
|
|
*
|
|
* Endpoint paths mirror the BFF routes 1:1 — there's no Next.js proxy
|
|
* for these in the current setup (BFF is on the same registrable domain
|
|
* in prod, and localhost is same-site in dev).
|
|
*/
|
|
|
|
const BFF_BASE =
|
|
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
|
|
|
export class SocialError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "SocialError";
|
|
}
|
|
}
|
|
|
|
type Envelope<T> = {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: { code: number; message: string };
|
|
meta?: { page: number; limit: number; total: number; total_pages: number };
|
|
};
|
|
|
|
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
|
...init,
|
|
});
|
|
if (res.status === 204) return undefined as T;
|
|
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
|
|
if (!res.ok || body.success === false) {
|
|
throw new SocialError(res.status, body.error?.message || res.statusText);
|
|
}
|
|
return (body.data ?? (undefined as unknown as T)) as T;
|
|
}
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────
|
|
|
|
export type PostImage = { id: string; url: string; position: number };
|
|
|
|
export type Post = {
|
|
id: string;
|
|
user_id: string;
|
|
caption: string;
|
|
source_url: string;
|
|
source_title: string;
|
|
images: PostImage[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
author_name?: string;
|
|
author_handle?: string;
|
|
author_avatar_url?: string | null;
|
|
/** True when the calling viewer has reshared this post. Populated by
|
|
* endpoints that go through OptionalAuth (single post, profile feed,
|
|
* collection contents). Always false / absent for anonymous viewers.
|
|
* Drives the post-card "Reshare ↔ Send" swap. */
|
|
viewer_has_reshared?: boolean;
|
|
};
|
|
|
|
export type Bookmark = {
|
|
id: string;
|
|
user_id: string;
|
|
source_url: string;
|
|
source_title: string;
|
|
excerpt: string | null;
|
|
note: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
export type Collection = {
|
|
id: string;
|
|
user_id: string;
|
|
title: string;
|
|
description: string | null;
|
|
cover_url: string | null;
|
|
is_public: boolean;
|
|
item_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type PublicProfile = {
|
|
handle: string;
|
|
name: string;
|
|
avatar_url: string | null;
|
|
joined_at: string;
|
|
post_count: number;
|
|
public_collections: Collection[];
|
|
};
|
|
|
|
// ─── Posts ───────────────────────────────────────────────────────────────
|
|
|
|
export const posts = {
|
|
create(input: {
|
|
caption: string;
|
|
source_url: string;
|
|
source_title?: string;
|
|
image_urls?: string[];
|
|
}) {
|
|
return call<Post>("/posts", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
caption: input.caption,
|
|
source_url: input.source_url,
|
|
source_title: input.source_title ?? "",
|
|
image_urls: input.image_urls ?? [],
|
|
}),
|
|
});
|
|
},
|
|
get(id: string) {
|
|
return call<Post>(`/posts/${id}`);
|
|
},
|
|
delete(id: string) {
|
|
return call<void>(`/posts/${id}`, { method: "DELETE" });
|
|
},
|
|
/** Swap the post's image set. Empty array removes all images. */
|
|
replaceImages(id: string, image_urls: string[]) {
|
|
return call<Post>(`/posts/${id}/images`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ image_urls }),
|
|
});
|
|
},
|
|
mine() {
|
|
return call<Post[]>("/posts/mine");
|
|
},
|
|
/** Record a reshare. Idempotent — calling twice is a no-op server
|
|
* side. Resharing your own post is rejected with 400 (not currently
|
|
* surfaced as a distinct error type because the UI only ever offers
|
|
* Reshare on non-owned posts). */
|
|
reshare(id: string) {
|
|
return call<void>(`/posts/${id}/reshare`, { method: "POST" });
|
|
},
|
|
/** Remove a previously recorded reshare. Idempotent. */
|
|
unreshare(id: string) {
|
|
return call<void>(`/posts/${id}/reshare`, { method: "DELETE" });
|
|
},
|
|
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
|
|
async uploadImage(file: File): Promise<string> {
|
|
const { upload_url, public_url } = await call<{
|
|
upload_url: string;
|
|
public_url: string;
|
|
}>("/posts/image-presign", {
|
|
method: "POST",
|
|
body: JSON.stringify({ content_type: file.type }),
|
|
});
|
|
const put = await fetch(upload_url, {
|
|
method: "PUT",
|
|
body: file,
|
|
headers: { "Content-Type": file.type },
|
|
});
|
|
if (!put.ok) throw new SocialError(put.status, "image upload failed");
|
|
return public_url;
|
|
},
|
|
};
|
|
|
|
// ─── Bookmarks ───────────────────────────────────────────────────────────
|
|
|
|
export const bookmarks = {
|
|
create(input: {
|
|
source_url: string;
|
|
source_title?: string;
|
|
excerpt?: string;
|
|
note?: string;
|
|
}) {
|
|
return call<Bookmark>("/bookmarks", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
source_url: input.source_url,
|
|
source_title: input.source_title ?? "",
|
|
excerpt: input.excerpt ?? null,
|
|
note: input.note ?? null,
|
|
}),
|
|
});
|
|
},
|
|
list() {
|
|
return call<Bookmark[]>("/bookmarks");
|
|
},
|
|
delete(id: string) {
|
|
return call<void>(`/bookmarks/${id}`, { method: "DELETE" });
|
|
},
|
|
};
|
|
|
|
// ─── Collections ─────────────────────────────────────────────────────────
|
|
|
|
export const collections = {
|
|
create(input: {
|
|
title: string;
|
|
description?: string;
|
|
cover_url?: string;
|
|
is_public?: boolean;
|
|
}) {
|
|
return call<Collection>("/collections", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: input.title,
|
|
description: input.description ?? null,
|
|
cover_url: input.cover_url ?? null,
|
|
is_public: !!input.is_public,
|
|
}),
|
|
});
|
|
},
|
|
update(
|
|
id: string,
|
|
input: Partial<{
|
|
title: string;
|
|
description: string;
|
|
cover_url: string;
|
|
is_public: boolean;
|
|
}>,
|
|
) {
|
|
return call<Collection>(`/collections/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
title: input.title ?? undefined,
|
|
description: input.description ?? undefined,
|
|
cover_url: input.cover_url === undefined ? undefined : input.cover_url,
|
|
is_public: input.is_public ?? undefined,
|
|
}),
|
|
});
|
|
},
|
|
delete(id: string) {
|
|
return call<void>(`/collections/${id}`, { method: "DELETE" });
|
|
},
|
|
mine() {
|
|
return call<Collection[]>("/collections");
|
|
},
|
|
/** Public-or-private depending on visibility. Anonymous viewers get 403 on private collections. */
|
|
get(id: string) {
|
|
return call<{ collection: Collection; posts: Post[] }>(`/collections/${id}`);
|
|
},
|
|
addItem(collectionID: string, postID: string) {
|
|
return call<{ ok: boolean }>(`/collections/${collectionID}/items`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ post_id: postID }),
|
|
});
|
|
},
|
|
removeItem(collectionID: string, postID: string) {
|
|
return call<void>(`/collections/${collectionID}/items/${postID}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
/** Returns the ids of the caller's collections that already contain
|
|
* the given post. The save-to-collection drawer uses this to disable
|
|
* rows so the post can't be added a second time. */
|
|
async containingPost(postID: string): Promise<string[]> {
|
|
const r = await call<{ collection_ids: string[] }>(
|
|
`/collections/containing/${postID}`,
|
|
);
|
|
return r.collection_ids ?? [];
|
|
},
|
|
/** Rewrite the position of every post in `post_ids` to match the array
|
|
* order. The BFF runs the updates in a single tx — readers see either
|
|
* the old order or the new one, never a half-applied state. */
|
|
reorderItems(collectionID: string, post_ids: string[]) {
|
|
return call<void>(`/collections/${collectionID}/items/order`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ post_ids }),
|
|
});
|
|
},
|
|
/** R2 presigned PUT for the collection cover. */
|
|
async uploadCover(file: File): Promise<string> {
|
|
const { upload_url, public_url } = await call<{
|
|
upload_url: string;
|
|
public_url: string;
|
|
}>("/collections/cover-presign", {
|
|
method: "POST",
|
|
body: JSON.stringify({ content_type: file.type }),
|
|
});
|
|
const put = await fetch(upload_url, {
|
|
method: "PUT",
|
|
body: file,
|
|
headers: { "Content-Type": file.type },
|
|
});
|
|
if (!put.ok) throw new SocialError(put.status, "cover upload failed");
|
|
return public_url;
|
|
},
|
|
};
|
|
|
|
// ─── Public profile ──────────────────────────────────────────────────────
|
|
|
|
export const profile = {
|
|
get(handle: string) {
|
|
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
|
|
},
|
|
feed(handle: string, limit = 20, offset = 0) {
|
|
const qs = new URLSearchParams({
|
|
limit: String(limit),
|
|
offset: String(offset),
|
|
});
|
|
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
|
|
},
|
|
};
|