feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
hh committed 2026-05-31 01:45:05 +02:00
1 parent f0afb7ec5b
commit 75425d1bee
110 files changed
+10199 -54

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
import { goto } from "$app/navigation";
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const MAX_LIMIT = 500;
export class ApiError extends Error {
status: number;
detail: string;
constructor(status: number, detail: string) {
super(detail);
this.name = "ApiError";
this.status = status;
this.detail = detail;
}
}
type QueryValue = string | number | boolean | null | undefined;
interface RequestOptions {
account?: boolean;
body?: unknown;
method?: string;
query?: Record<string, QueryValue>;
}
function buildQuery(
query: Record<string, QueryValue> | undefined,
withAccount: boolean
): string {
const params = new URLSearchParams();
if (withAccount && accounts.selectedId !== null) {
params.set("account_id", String(accounts.selectedId));
}
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) {
continue;
}
const clamped =
key === "limit" && typeof value === "number"
? Math.min(value, MAX_LIMIT)
: value;
params.set(key, String(clamped));
}
}
const text = params.toString();
return text ? `?${text}` : "";
}
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
async function handleError(response: Response): Promise<never> {
if (response.status === 401) {
auth.logout();
await goto("/login");
}
let detail = response.statusText;
try {
const data = await response.json();
if (data && typeof data.detail === "string") {
detail = data.detail;
}
} catch {
detail = response.statusText;
}
throw new ApiError(response.status, detail);
}
export async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { method = "GET", query, body, account = false } = options;
const headers: Record<string, string> = authHeaders();
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${BASE}${path}${buildQuery(query, account)}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
return handleError(response);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export type MediaResult =
| { state: "ready"; url: string; mime: string | null }
| { state: "not-downloaded" }
| { state: "missing" };
export async function requestMedia(mediaId: number): Promise<MediaResult> {
const response = await fetch(`${BASE}/media/${mediaId}`, {
headers: authHeaders(),
});
if (response.status === 409) {
return { state: "not-downloaded" };
}
if (response.status === 404) {
return { state: "missing" };
}
if (!response.ok) {
return handleError(response);
}
const blob = await response.blob();
return {
state: "ready",
url: URL.createObjectURL(blob),
mime: response.headers.get("Content-Type"),
};
}
export async function requestMediaVersion(
versionId: number
): Promise<MediaResult> {
const response = await fetch(`${BASE}/media/version/${versionId}`, {
headers: authHeaders(),
});
if (response.status === 404) {
return { state: "missing" };
}
if (!response.ok) {
return handleError(response);
}
const blob = await response.blob();
return {
state: "ready",
url: URL.createObjectURL(blob),
mime: response.headers.get("Content-Type"),
};
}