From ab6ab614ce94410a1987ea801e04b7c43ce0301f Mon Sep 17 00:00:00 2001 From: june Date: Tue, 12 Aug 2025 12:18:07 +1200 Subject: [PATCH] We FINALLY got this working lfg --- src/lib/pocketbase/index.ts | 288 +++++++++++++++++++++++++ src/lib/pocketbase/pocketbase-types.ts | 158 ++++++++++++++ src/routes/+page.svelte | 16 +- src/routes/+page.ts | 18 ++ 4 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 src/lib/pocketbase/index.ts create mode 100644 src/lib/pocketbase/pocketbase-types.ts create mode 100644 src/routes/+page.ts diff --git a/src/lib/pocketbase/index.ts b/src/lib/pocketbase/index.ts new file mode 100644 index 0000000..67da881 --- /dev/null +++ b/src/lib/pocketbase/index.ts @@ -0,0 +1,288 @@ +// Adapted from https://github.com/spinspire/pocketbase-sveltekit-starter + +import PocketBase, { type AuthProviderInfo, RecordService } from "pocketbase"; +import type { + AuthModel, + ListResult, + RecordListOptions, + RecordModel, + UnsubscribeFunc, +} from "pocketbase"; +import { readable, type Readable, type Subscriber } from "svelte/store"; +import { browser } from "$app/environment"; +// import { PB_URL } from "$env/static/private"; +import { base } from "$app/paths"; +import { invalidateAll } from "$app/navigation"; +import type { TypedPocketBase } from "./pocketbase-types"; // npx pocketbase-typegen --url http://localhost:8080 --email admin@example.com --password 'secret-password' +// import { +// startAuthentication, +// startRegistration, +// } from "@simplewebauthn/browser"; +// import { alerts } from "$lib/components/Alerts.svelte"; + +export const client = new PocketBase( + // browser ? window.location.origin + base : undefined + // "http://pb:8080" + "http://localhost:8080" // FIXME: use env +) as TypedPocketBase; + +export const authModel = readable( + null, + function (set, update) { + client.authStore.onChange((token, model) => { + update((oldval) => { + if ( + (oldval?.isValid && !model?.isValid) || + (!oldval?.isValid && model?.isValid) + ) { + // if the auth changed, invalidate all page load data + invalidateAll(); + } + return model; + }); + }, true); + } +); + +export async function login( + email: string, + password: string, + register = false, + rest: { [key: string]: any } = {} +) { + if (register) { + const user = { ...rest, email, password, confirmPassword: password }; + await client.collection("users").create({ ...user, metadata: {} }); + } + await client.collection("users").authWithPassword(email, password); +} + +// export async function webauthnRegister( +// usernameOrEmail: string +// ): Promise { +// try { +// const resp = await fetch( +// `/api/webauthn/registration-options?usernameOrEmail=${usernameOrEmail}` +// ); +// if (!resp.ok) { +// const text = await resp.text(); +// throw new Error(text); +// } +// const { publicKey: optionsJSON } = await resp.json(); +// let attResp = await startRegistration({ optionsJSON }); +// const res = await fetch(`/api/webauthn/register`, { +// method: "POST", +// headers: { +// "Content-Type": "application/json", +// }, +// body: JSON.stringify({ ...attResp, usernameOrEmail }), +// }); +// if (!res.ok) throw new Error("Failed to register"); +// alerts.success("Passkey registered successfully."); +// } catch (e) { +// if (e instanceof Error) { +// if (e.name === "NotAllowedError") { +// alerts.error("Registration denied or timed out."); +// } +// console.error(e); +// } +// } +// } + +// export async function webauthnLogin(usernameOrEmail: string) { +// try { +// const resp = await fetch( +// `/api/webauthn/login-options?usernameOrEmail=${usernameOrEmail}` +// ); +// if (!resp.ok) { +// const text = await resp.text(); +// throw new Error(text); +// } +// const { publicKey: optionsJSON } = await resp.json(); +// let asseResp = await startAuthentication({ optionsJSON }); +// const res = await fetch(`/api/webauthn/login`, { +// method: "POST", +// headers: { +// "Content-Type": "application/json", +// }, +// body: JSON.stringify({ ...asseResp, usernameOrEmail }), +// }); +// const authResponse = await res.json(); +// if (!res.ok) throw new Error("Failed to login"); +// client.authStore.save(authResponse.token, authResponse.record); +// } catch (e) { +// if (e instanceof Error) { +// if (e.name === "NotAllowedError") { +// alerts.error("Registration denied or timed out."); +// } else { +// alerts.error(e.message); +// } +// console.error(e); +// } +// } +// } + +export function logout() { + client.authStore.clear(); +} + +/* + * Save (create/update) a record (a plain object). Automatically converts to + * FormData if needed. + */ +export async function save(collection: string, record: any, create = false) { + // convert obj to FormData in case one of the fields is instanceof FileList + const data = object2formdata(record); + if (record.id && !create) { + // "create" flag overrides update + return await client.collection(collection).update(record.id, data); + } else { + return await client.collection(collection).create(data); + } +} + +// convert obj to FormData in case one of the fields is instanceof FileList +function object2formdata(obj: {}) { + // check if any field's value is an instanceof FileList + if ( + !Object.values(obj).some( + (val) => val instanceof FileList || val instanceof File + ) + ) { + // if not, just return the original object + return obj; + } + // otherwise, build FormData (multipart/form-data) from obj + const fd = new FormData(); + for (const [key, val] of Object.entries(obj)) { + if (val instanceof FileList) { + for (const file of val) { + fd.append(key, file); + } + } else if (val instanceof File) { + // handle File before "object" so that it doesn't get serialized as JSON + fd.append(key, val); + } else if (Array.isArray(val)) { + // for some reason, multipart/form-data wants arrays to be comma-separated strings + fd.append(key, val.join(",")); + } else if (typeof val === "object") { + fd.append(key, JSON.stringify(val)); + } else { + fd.append(key, val as any); + } + } + return fd; +} + +export interface PageStore extends Readable> { + setPage(newpage: number): Promise; + next(): Promise; + prev(): Promise; +} + +export async function watch( + idOrName: string, + queryParams = {} as RecordListOptions, + page = 1, + perPage = 20, + realtime = browser +): Promise> { + const collection = client.collection(idOrName); + let result = await collection.getList(page, perPage, queryParams); + let set: Subscriber>; + let unsubRealtime: UnsubscribeFunc | undefined; + // fetch first page + const store = readable>(result, (_set) => { + set = _set; + // watch for changes (only if you're in the browser) + if (realtime) + collection + .subscribe( + "*", + ({ action, record }) => { + (async function (action: string) { + // see https://github.com/pocketbase/pocketbase/discussions/505 + switch (action) { + // ISSUE: no subscribe event when a record is modified and no longer fits the "filter" + // @see https://github.com/pocketbase/pocketbase/issues/4717 + case "update": + case "create": + // record = await expand(queryParams.expand, record); + const index = result.items.findIndex( + (r) => r.id === record.id + ); + // replace existing if found, otherwise append + if (index >= 0) { + result.items[index] = record; + return result.items; + } else { + return [...result.items, record]; + } + case "delete": + return result.items.filter((item) => item.id !== record.id); + } + return result.items; + })(action).then((items) => set((result = { ...result, items }))); + }, + queryParams + ) + // remember for later + .then((unsub) => (unsubRealtime = unsub)); + }); + async function setPage(newpage: number) { + const { page, totalPages, perPage } = result; + if (page > 0 && page <= totalPages) { + set((result = await collection.getList(newpage, perPage, queryParams))); + } + } + return { + ...store, + subscribe(run, invalidate) { + const unsubStore = store.subscribe(run, invalidate); + return async () => { + unsubStore(); + // ISSUE: Technically, we should AWAIT here, but that will slow down navigation UX. + if (unsubRealtime) /* await */ unsubRealtime(); + }; + }, + setPage, + async next() { + setPage(result.page + 1); + }, + async prev() { + setPage(result.page - 1); + }, + }; +} + +// export async function providerLogin( +// provider: AuthProviderInfo, +// authCollection: RecordService +// ) { +// const authResponse = await authCollection.authWithOAuth2({ +// provider: provider.name, +// createData: { +// // emailVisibility: true, +// }, +// }); +// // update user "record" if "meta" has info it doesn't have +// const { meta, record } = authResponse; +// let changes = {} as { [key: string]: any }; +// if (!record.name && meta?.name) { +// changes.name = meta.name; +// } +// if (!record.avatar && meta?.avatarUrl) { +// const response = await fetch(meta.avatarUrl); +// if (response.ok) { +// const type = response.headers.get("content-type") ?? "image/jpeg"; +// changes.avatar = new File([await response.blob()], "avatar", { type }); +// } +// } +// if (Object.keys(changes).length) { +// authResponse.record = await save(authCollection.collectionIdOrName, { +// ...record, +// ...changes, +// }); +// } +// return authResponse; +// } \ No newline at end of file diff --git a/src/lib/pocketbase/pocketbase-types.ts b/src/lib/pocketbase/pocketbase-types.ts new file mode 100644 index 0000000..bc5b702 --- /dev/null +++ b/src/lib/pocketbase/pocketbase-types.ts @@ -0,0 +1,158 @@ +/** +* This file was @generated using pocketbase-typegen +*/ + +import type PocketBase from 'pocketbase' +import type { RecordService } from 'pocketbase' + +export enum Collections { + Authorigins = "_authOrigins", + Externalauths = "_externalAuths", + Mfas = "_mfas", + Otps = "_otps", + Superusers = "_superusers", + Recipes = "recipes", + Users = "users", +} + +// Alias types for improved usability +export type IsoDateString = string +export type RecordIdString = string +export type HTMLString = string + +type ExpandType = unknown extends T + ? T extends unknown + ? { expand?: unknown } + : { expand: T } + : { expand: T } + +// System fields +export type BaseSystemFields = { + id: RecordIdString + collectionId: string + collectionName: Collections +} & ExpandType + +export type AuthSystemFields = { + email: string + emailVisibility: boolean + username: string + verified: boolean +} & BaseSystemFields + +// Record types for each collection + +export type AuthoriginsRecord = { + collectionRef: string + created?: IsoDateString + fingerprint: string + id: string + recordRef: string + updated?: IsoDateString +} + +export type ExternalauthsRecord = { + collectionRef: string + created?: IsoDateString + id: string + provider: string + providerId: string + recordRef: string + updated?: IsoDateString +} + +export type MfasRecord = { + collectionRef: string + created?: IsoDateString + id: string + method: string + recordRef: string + updated?: IsoDateString +} + +export type OtpsRecord = { + collectionRef: string + created?: IsoDateString + id: string + password: string + recordRef: string + sentTo?: string + updated?: IsoDateString +} + +export type SuperusersRecord = { + created?: IsoDateString + email: string + emailVisibility?: boolean + id: string + password: string + tokenKey: string + updated?: IsoDateString + verified?: boolean +} + +export type RecipesRecord = { + created?: IsoDateString + desc?: string + id: string + name?: string + servings?: number + updated?: IsoDateString +} + +export type UsersRecord = { + avatar?: string + created?: IsoDateString + email: string + emailVisibility?: boolean + id: string + name?: string + password: string + tokenKey: string + updated?: IsoDateString + verified?: boolean +} + +// Response types include system fields and match responses from the PocketBase API +export type AuthoriginsResponse = Required & BaseSystemFields +export type ExternalauthsResponse = Required & BaseSystemFields +export type MfasResponse = Required & BaseSystemFields +export type OtpsResponse = Required & BaseSystemFields +export type SuperusersResponse = Required & AuthSystemFields +export type RecipesResponse = Required & BaseSystemFields +export type UsersResponse = Required & AuthSystemFields + +// Types containing all Records and Responses, useful for creating typing helper functions + +export type CollectionRecords = { + _authOrigins: AuthoriginsRecord + _externalAuths: ExternalauthsRecord + _mfas: MfasRecord + _otps: OtpsRecord + _superusers: SuperusersRecord + recipes: RecipesRecord + users: UsersRecord +} + +export type CollectionResponses = { + _authOrigins: AuthoriginsResponse + _externalAuths: ExternalauthsResponse + _mfas: MfasResponse + _otps: OtpsResponse + _superusers: SuperusersResponse + recipes: RecipesResponse + users: UsersResponse +} + +// Type for usage with type asserted PocketBase instance +// https://github.com/pocketbase/js-sdk#specify-typescript-definitions + +export type TypedPocketBase = PocketBase & { + collection(idOrName: '_authOrigins'): RecordService + collection(idOrName: '_externalAuths'): RecordService + collection(idOrName: '_mfas'): RecordService + collection(idOrName: '_otps'): RecordService + collection(idOrName: '_superusers'): RecordService + collection(idOrName: 'recipes'): RecordService + collection(idOrName: 'users'): RecordService +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e40fec9..6336b20 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,12 +1,16 @@ -

Welcome to SvelteKit

Visit svelte.dev/docs/kit to read the documentation

+ + +{#each $recipes.items as item} + {item.name}
+ {item.desc} + +{/each} \ No newline at end of file diff --git a/src/routes/+page.ts b/src/routes/+page.ts new file mode 100644 index 0000000..1c286cb --- /dev/null +++ b/src/routes/+page.ts @@ -0,0 +1,18 @@ +import { client, watch } from "$lib/pocketbase"; +import type { RecipesResponse } from "$lib/pocketbase/pocketbase-types"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ parent, fetch }) => { +// const filter = client.filter("user != ''", {}); + const expand = "user"; + const queryParams = { + // filter, + expand, + fetch, + }; + const recipes = await watch>("recipes", queryParams); + + return { + recipes, + }; +}; \ No newline at end of file