From c3fa67af4ddc1594e9ab430de3aa189ecb0184a5 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Tue, 27 Oct 2026 07:47:57 +0000 Subject: [PATCH 1/2] The web and the iPhone app take library actions through one createLibrary that owns each route, body and error; the iPhone app refetches what each makes stale in one place Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Per4f8yL5A7nL9frZVDJj7 Signed-off-by: NiallJoeMaher --- apps/ios/app/archive.tsx | 9 +-- apps/ios/app/bin.tsx | 9 +-- apps/ios/app/lists/[id].tsx | 26 ++----- apps/ios/app/pick.tsx | 43 ++++-------- apps/ios/app/tags.tsx | 13 +--- apps/ios/components/ItemsScreen.tsx | 11 +-- apps/ios/lib/data.ts | 38 ++++++---- apps/web/app/(app)/archive/ArchiveView.tsx | 6 +- apps/web/app/(app)/items/[id]/NoteEditor.tsx | 7 +- apps/web/app/(app)/items/[id]/Preview.tsx | 3 +- apps/web/app/(app)/items/[id]/Reader.tsx | 8 +-- apps/web/app/(app)/lists/[id]/ListView.tsx | 11 ++- apps/web/app/(app)/settings/TagsSection.tsx | 13 ++-- apps/web/components/ItemBar.tsx | 5 +- apps/web/components/ItemList.tsx | 20 +++--- apps/web/components/ItemLists.tsx | 12 ++-- apps/web/components/ItemTags.tsx | 16 ++--- apps/web/lib/actions.ts | 17 +++++ apps/web/lib/listing.ts | 26 ------- apps/web/lib/moves.ts | 18 ----- apps/web/lib/tagging.ts | 29 -------- docs/architecture.md | 11 +-- packages/client/package.json | 2 +- packages/client/src/api.ts | 34 +++++---- packages/client/src/index.ts | 3 +- packages/client/src/library.test.ts | 74 ++++++++++++++++++++ packages/client/src/library.ts | 67 ++++++++++++++++++ 27 files changed, 283 insertions(+), 248 deletions(-) create mode 100644 apps/web/lib/actions.ts delete mode 100644 apps/web/lib/listing.ts delete mode 100644 apps/web/lib/moves.ts delete mode 100644 apps/web/lib/tagging.ts create mode 100644 packages/client/src/library.test.ts create mode 100644 packages/client/src/library.ts diff --git a/apps/ios/app/archive.tsx b/apps/ios/app/archive.tsx index 52de543..51815b2 100644 --- a/apps/ios/app/archive.tsx +++ b/apps/ios/app/archive.tsx @@ -11,10 +11,9 @@ import { Icon, ICONS } from "@/components/Glyph"; import { ItemRow } from "@/components/ItemRow"; import { Empty, usePull } from "@/components/Screen"; import { toast } from "@/components/Toast"; -import { keys, useArchive, useMe, usePatchItem } from "@/lib/data"; +import { keys, library, useArchive, useMe, usePatchItem } from "@/lib/data"; import { useRowText } from "@/lib/format"; import { kv } from "@/lib/kv"; -import { api } from "@/lib/session"; import { font, scale, useTheme } from "@/lib/theme"; const FOLD = "fold:archive"; // the months folded away, remembered on this phone @@ -78,13 +77,11 @@ export default function Archive() { qc.setQueryData(keys.archive, without); void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); try { - await api(`/api/v1/items/${item.id}`, { method: "DELETE" }); + await library.remove(item.id); } catch (e) { - void qc.invalidateQueries({ queryKey: keys.archive }); return toast(e instanceof Refused && e.status === 403 ? tr("archive.needsSignIn") : tr("common.failed.title")); } - void qc.invalidateQueries({ queryKey: keys.bin }); - toast(tr("moved.deleted"), () => void api(`/api/v1/items/${item.id}/restore`, { method: "POST" }).then(() => qc.invalidateQueries({ queryKey: ["items"] }))); + toast(tr("moved.deleted"), () => void library.fromBin(item.id)); }; return ( diff --git a/apps/ios/app/bin.tsx b/apps/ios/app/bin.tsx index 4461261..4027f8e 100644 --- a/apps/ios/app/bin.tsx +++ b/apps/ios/app/bin.tsx @@ -10,9 +10,8 @@ import { Icon, ICONS } from "@/components/Glyph"; import { ItemRow } from "@/components/ItemRow"; import { Empty, usePull } from "@/components/Screen"; import { toast } from "@/components/Toast"; -import { keys, useBin } from "@/lib/data"; +import { keys, library, useBin } from "@/lib/data"; import { useRowText } from "@/lib/format"; -import { api } from "@/lib/session"; import { font, scale, useTheme } from "@/lib/theme"; const DAY = 86_400_000; @@ -35,8 +34,7 @@ export default function Bin() { const restore = async (item: Item) => { drop(item); - await api(`/api/v1/items/${item.id}/restore`, { method: "POST" }).catch(() => toast(tr("common.failed.title"))); - void qc.invalidateQueries({ queryKey: ["items"] }); + await library.fromBin(item.id).catch(() => toast(tr("common.failed.title"))); toast(tr("archive.outOfBin")); }; @@ -44,10 +42,9 @@ export default function Bin() { drop(item); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); try { - await api(`/api/v1/items/${item.id}/forever`, { method: "DELETE" }); + await library.forever(item.id); toast(tr("archive.gone")); } catch (e) { - void qc.invalidateQueries({ queryKey: keys.bin }); toast(e instanceof Refused && e.status === 403 ? tr("archive.needsSignIn") : tr("common.failed.title")); } }; diff --git a/apps/ios/app/lists/[id].tsx b/apps/ios/app/lists/[id].tsx index 36a7480..4467123 100644 --- a/apps/ios/app/lists/[id].tsx +++ b/apps/ios/app/lists/[id].tsx @@ -11,9 +11,8 @@ import { ItemRow } from "@/components/ItemRow"; import { Empty } from "@/components/Screen"; import { toast } from "@/components/Toast"; import { ToolBar } from "@/components/ToolBar"; -import { keys, useList } from "@/lib/data"; +import { keys, library, useList } from "@/lib/data"; import { kv } from "@/lib/kv"; -import { api } from "@/lib/session"; import { font, scale, useTheme } from "@/lib/theme"; type Row = { e: ListEntry; n: number } | "head"; @@ -52,42 +51,27 @@ export default function ListScreen() { const chosen = entries.filter((e) => picked?.has(e.item.id)); const edit = (fn: (entries: ListEntry[]) => ListEntry[]) => qc.setQueryData(keys.list(id), (old) => old && { ...old, entries: fn(old.entries) }); - const refresh = () => { - void qc.invalidateQueries({ queryKey: keys.list(id) }); - void qc.invalidateQueries({ queryKey: keys.lists }); - void qc.invalidateQueries({ queryKey: ["items"] }); // a Todo marked here is Done there - void qc.invalidateQueries({ queryKey: ["counts"] }); - }; const failed = () => toast(tr("common.failed.title")); const mark = async (ids: string[], ticked: boolean) => { edit((es) => es.map((e) => (ids.includes(e.item.id) ? { ...e, ticked } : e))); void Haptics.selectionAsync(); - await (ids.length === 1 - ? api(`/api/v1/lists/${id}/items/${ids[0]}`, { method: "PATCH", json: { ticked } }) - : api(`/api/v1/lists/${id}/items`, { method: "PATCH", json: { itemIds: ids, ticked } }) - ).catch(failed); - refresh(); + await library.mark(id, ids, ticked).catch(failed); }; const toTop = async () => { const order = [...chosen, ...entries.filter((e) => !picked?.has(e.item.id))]; setPicked(null); edit(() => order); - await api(`/api/v1/lists/${id}/reorder`, { json: { itemIds: order.map((e) => e.item.id) } }).catch(failed); - refresh(); + await library.reorder(id, order.map((e) => e.item.id)).catch(failed); }; const takeOut = async () => { const gone = chosen; setPicked(null); edit((es) => es.filter((e) => !gone.includes(e))); - for (const e of gone) await api(`/api/v1/lists/${id}/items/${e.item.id}`, { method: "DELETE" }).catch(failed); - refresh(); - toast(tr("list.takenOut", { count: gone.length }), async () => { - for (const e of gone) await api(`/api/v1/lists/${id}/items`, { json: { itemId: e.item.id, position: e.position, note: e.note } }).catch(failed); - refresh(); - }); + await library.setInList(gone.map((e) => e.item.id), id, false).catch(failed); + toast(tr("list.takenOut", { count: gone.length }), () => void library.putBack(id, gone).catch(failed)); }; const share = () => router.push({ pathname: "/share", params: { id } }); diff --git a/apps/ios/app/pick.tsx b/apps/ios/app/pick.tsx index d265c46..80e4a13 100644 --- a/apps/ios/app/pick.tsx +++ b/apps/ios/app/pick.tsx @@ -1,4 +1,4 @@ -import type { Item, ItemPage, List, Tag } from "@pluck/shared-schemas"; +import type { Item, ItemPage } from "@pluck/shared-schemas"; import { useQueryClient } from "@tanstack/react-query"; import { router, useLocalSearchParams } from "expo-router"; import { useState, type ReactNode } from "react"; @@ -7,8 +7,7 @@ import { useTranslations } from "use-intl"; import { Icon, ICONS } from "@/components/Glyph"; import { ItemRow } from "@/components/ItemRow"; import { Empty } from "@/components/Screen"; -import { keys, useItem, useItems, useList, useLists, usePatchItem, useTags } from "@/lib/data"; -import { api } from "@/lib/session"; +import { library, useItem, useItems, useList, useLists, usePatchItem, useTags } from "@/lib/data"; import { font, scale, useTheme } from "@/lib/theme"; // One sheet for three picks (no board: built from the web pickers): an item's tags, an item's lists, @@ -69,7 +68,6 @@ function Row({ label, on, onPress, left }: { label: string; on: boolean; onPress // Search your tags, or make one from what you typed: one tap either way. function TagsFor({ itemId }: { itemId: string }) { const tr = useTranslations("ios.pick"); - const qc = useQueryClient(); const item = useItem(itemId); const tags = useTags(); const patch = usePatchItem(); @@ -79,10 +77,9 @@ function TagsFor({ itemId }: { itemId: string }) { const shown = (tags.data ?? []).filter((x) => x.name.toLowerCase().includes(name.toLowerCase())); const fresh = name && !(tags.data ?? []).some((x) => x.name.toLowerCase() === name.toLowerCase()); const make = async () => { - const { tag } = await api<{ tag: Tag }>("/api/v1/tags", { json: { name } }); + const tag = await library.newTag(name); patch.mutate({ id: itemId, patch: { tagIds: [...on, tag.id] } }); setFilter(""); - void qc.invalidateQueries({ queryKey: keys.tags }); }; return ( l.name.toLowerCase().includes(name.toLowerCase())); const fresh = name && !(lists.data ?? []).some((l) => l.name.toLowerCase() === name.toLowerCase()); - const settle = async (listId?: string) => { - await item.refetch(); - void qc.invalidateQueries({ queryKey: keys.lists }); - if (listId) void qc.invalidateQueries({ queryKey: keys.list(listId) }); - }; const toggle = async (listId: string) => { - if (inLists.has(listId)) await api(`/api/v1/lists/${listId}/items/${itemId}`, { method: "DELETE" }).catch(() => {}); - else await api(`/api/v1/lists/${listId}/items`, { json: { itemId } }).catch(() => {}); - await settle(listId); + await library.setInList([itemId], listId, !inLists.has(listId)).catch(() => {}); + await item.refetch(); }; const start = async () => { - await api<{ list: List }>("/api/v1/lists", { json: { name, items: [{ itemId }] } }); + await library.newList(name, [itemId]); setFilter(""); - await settle(); + await item.refetch(); }; return ( x.name.toLowerCase() === name.toLowerCase()); const apply = async (tagId: string, on: boolean) => { setAll((cur) => { const next = new Set(cur); if (on) next.add(tagId); else next.delete(tagId); return next; }); - await api("/api/v1/items/batch", { json: { itemIds: ids, patch: on ? { addTagIds: [tagId] } : { removeTagIds: [tagId] } } }).catch(() => {}); - void qc.invalidateQueries({ queryKey: ["items"] }); - void qc.invalidateQueries({ queryKey: keys.tags }); + await library.setTag(ids, tagId, on, loaded).catch(() => {}); }; const make = async () => { - const { tag } = await api<{ tag: Tag }>("/api/v1/tags", { json: { name } }); + const tag = await library.newTag(name); setFilter(""); await apply(tag.id, true); }; @@ -177,7 +165,6 @@ function TagsForMany({ ids }: { ids: string[] }) { // Lists for many selected rows: a tap adds them all; a new list can start with them. function ListsForMany({ ids }: { ids: string[] }) { const tr = useTranslations("ios.pick"); - const qc = useQueryClient(); const lists = useLists(); const [filter, setFilter] = useState(""); const [added, setAdded] = useState>(new Set()); @@ -187,15 +174,12 @@ function ListsForMany({ ids }: { ids: string[] }) { const add = async (listId: string) => { if (added.has(listId)) return; setAdded((cur) => new Set(cur).add(listId)); - await api(`/api/v1/lists/${listId}/items`, { json: { itemIds: ids } }).catch(() => {}); - void qc.invalidateQueries({ queryKey: keys.lists }); - void qc.invalidateQueries({ queryKey: keys.list(listId) }); + await library.setInList(ids, listId, true).catch(() => {}); }; const start = async () => { - const { list } = await api<{ list: List }>("/api/v1/lists", { json: { name, items: ids.map((itemId) => ({ itemId })) } }); + const list = await library.newList(name, ids); setFilter(""); setAdded((cur) => new Set(cur).add(list.id)); - void qc.invalidateQueries({ queryKey: keys.lists }); }; return ( { if (inList.has(itemId)) return; - await api(`/api/v1/lists/${listId}/items`, { json: { itemId } }).catch(() => {}); - await list.refetch(); - void qc.invalidateQueries({ queryKey: keys.lists }); + await library.setInList([itemId], listId, true).catch(() => {}); }; return ( { - void qc.invalidateQueries({ queryKey: keys.tags }); - void qc.invalidateQueries({ queryKey: ["items"] }); - }; const failed = (e: unknown) => Alert.alert(tr("common.failed.title"), e instanceof Refused ? e.message : tr("common.failed.body")); const rename = (tag: Tag) => Alert.prompt(tr("tags.renameTitle"), undefined, [ { text: tr("common.cancel"), style: "cancel" }, - { text: tr("common.save"), onPress: (name?: string) => void api(`/api/v1/tags/${tag.id}`, { method: "PATCH", json: { name: name ?? "" } }).then(settle).catch(failed) }, + { text: tr("common.save"), onPress: (name?: string) => void library.renameTag(tag.id, name ?? "").catch(failed) }, ], "plain-text", tag.name); const remove = (tag: Tag) => Alert.alert(tr("tags.deleteTitle", { name: tag.name }), tr("tags.deleteBody", { count: tag.count }), [ { text: tr("common.cancel"), style: "cancel" }, { text: tr("tags.deleteTag"), style: "destructive", onPress: () => - void api(`/api/v1/tags/${tag.id}?removeFromItems=true`, { method: "DELETE" }).then(() => { settle(); toast(tr("tags.deleted", { name: tag.name })); }).catch(failed), + void library.deleteTag(tag.id).then(() => toast(tr("tags.deleted", { name: tag.name }))).catch(failed), }, ]); const open = (tag: Tag) => diff --git a/apps/ios/components/ItemsScreen.tsx b/apps/ios/components/ItemsScreen.tsx index 4b25c37..3fe0f78 100644 --- a/apps/ios/components/ItemsScreen.tsx +++ b/apps/ios/components/ItemsScreen.tsx @@ -6,8 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ActionSheetIOS, ActivityIndicator, Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"; import Animated, { FadeInUp } from "react-native-reanimated"; import { useTranslations } from "use-intl"; -import { api } from "@/lib/session"; -import { keys, shareExtensionCache, syncPending, useCounts, useItems, useLists, useMe, useOnline, usePatchItem, usePending, useTags } from "@/lib/data"; +import { keys, library, shareExtensionCache, syncPending, useCounts, useItems, useLists, useMe, useOnline, usePatchItem, usePending, useTags } from "@/lib/data"; import { font, scale, useTheme } from "@/lib/theme"; import { ShareRow, SwipeToLater } from "./Art"; import { Icon, ICONS } from "./Glyph"; @@ -80,12 +79,8 @@ export function ItemsScreen({ status }: { status: "inbox" | "later" }) { setPicked(null); qc.setQueryData(keys.items(status), (old: typeof items.data) => (old ? { ...old, items: old.items.filter((i) => !ids.includes(i.id)) } : old)); void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - const send = (s: Status) => api("/api/v1/items/batch", { json: { itemIds: ids, patch: { status: s } } }).finally(() => { - void qc.invalidateQueries({ queryKey: ["items"] }); - void qc.invalidateQueries({ queryKey: ["counts"] }); - }); - await send(to).catch(() => toast(tr("items.moveFailed"))); - toast(tr(`movedMany.${to}`, { count: ids.length }), () => void send(status)); + await library.move(ids, to).catch(() => toast(tr("items.moveFailed"))); + toast(tr(`movedMany.${to}`, { count: ids.length }), () => void library.restore(moved)); }; const away: Status = status === "later" ? "inbox" : "later"; diff --git a/apps/ios/lib/data.ts b/apps/ios/lib/data.ts index ae06d43..f5fb5d0 100644 --- a/apps/ios/lib/data.ts +++ b/apps/ios/lib/data.ts @@ -1,12 +1,12 @@ import { ExtensionStorage } from "@bacons/apple-targets"; -import type { CreateItemBody, Highlight, Item, ItemPage, List, ListEntry, ListMembership, Me, PluckResponse, SearchResults, Status, Tag } from "@pluck/shared-schemas"; -import { focusManager, onlineManager, QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { CreateItemBody, Highlight, Item, ItemPage, List, ListEntry, ListMembership, Me, PluckResponse, SearchResults, Status, Tag, UpdateItemBody } from "@pluck/shared-schemas"; +import { focusManager, onlineManager, QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient, type QueryKey } from "@tanstack/react-query"; import Constants from "expo-constants"; import { Directory, File, Paths } from "expo-file-system"; import * as Network from "expo-network"; import { useEffect, useState, useSyncExternalStore } from "react"; import { AppState } from "react-native"; -import { createDrain, createQueue, Unreachable } from "@pluck/client"; +import { createDrain, createLibrary, createQueue, Unreachable } from "@pluck/client"; import { KEEP, MAX_BYTES, onWifi, overCap, plan, toKeep, type Kept } from "./keep"; import { kv } from "./kv"; import { api, authHeader, getSession } from "./session"; @@ -47,7 +47,24 @@ export const keys = { pending: ["pending"] as const, }; -export type FullItem = { item: Item; text: string; highlights: Highlight[]; lists: ListMembership[] }; +// The library actions (packages/client), each followed by the queries it makes stale, whether or not it went through +// (a failure refetches the truth over what the screen assumed). +const stale = (...ks: QueryKey[]) => { for (const queryKey of ks) void queryClient.invalidateQueries({ queryKey }); }; +const moved = () => stale(["items"], ["counts"], keys.lists); // the Item lists (Archive and bin too), their counts, Lists' rows +const tagged = () => stale(keys.tags, ["items"]); +const listed = (listId?: string) => stale(keys.lists, ...(listId ? [keys.list(listId)] : []), ["items"], ["counts"]); // a Todo marked in a List is Done +const after = (act: (...a: A) => Promise, then: (...a: A) => void) => (...a: A) => act(...a).finally(() => then(...a)); +const lib = createLibrary(api); +export const library = { + ...lib, + move: after(lib.move, moved), restore: after(lib.restore, moved), edit: after(lib.edit, moved), + remove: after(lib.remove, moved), fromBin: after(lib.fromBin, moved), forever: after(lib.forever, moved), + setTag: after(lib.setTag, tagged), newTag: after(lib.newTag, tagged), renameTag: after(lib.renameTag, tagged), deleteTag: after(lib.deleteTag, tagged), + newList: after(lib.newList, () => listed()), setInList: after(lib.setInList, (_, listId) => listed(listId)), + mark: after(lib.mark, listed), reorder: after(lib.reorder, listed), putBack: after(lib.putBack, listed), +}; + +export type FullItem ={ item: Item; text: string; highlights: Highlight[]; lists: ListMembership[] }; export const useItems = (status: Status) => useQuery({ queryKey: keys.items(status), queryFn: () => api(`/api/v1/items?status=${status}&limit=50`), refetchOnWindowFocus: "always" }); @@ -98,10 +115,10 @@ export function useCover(item: Pick, path?: string): { }, [item.id, item.hasCover, path]); return src; } -export const useLists = () => useQuery({ queryKey: keys.lists, queryFn: async () => (await api<{ lists: List[] }>("/api/v1/lists")).lists }); +export const useLists = () => useQuery({ queryKey: keys.lists, queryFn: () => library.lists() }); export const useList = (id: string) => useQuery({ queryKey: keys.list(id), queryFn: () => api<{ list: List; entries: ListEntry[] }>(`/api/v1/lists/${id}`) }); -export const useTags = () => useQuery({ queryKey: keys.tags, queryFn: async () => (await api<{ tags: Tag[] }>("/api/v1/tags")).tags }); +export const useTags = () => useQuery({ queryKey: keys.tags, queryFn: () => library.tags() }); export const useCounts = () => useQuery({ queryKey: ["counts"], queryFn: () => api<{ inbox: number; later: number; lists: number }>("/api/v1/counts"), refetchOnWindowFocus: "always" }); export const useMe = () => useQuery({ queryKey: keys.me, queryFn: async () => (await api<{ me: Me }>("/api/v1/me")).me }); @@ -126,13 +143,8 @@ export const useSearch = (q: string, f: SearchFilters) => export function usePatchItem() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, patch }: { id: string; patch: Record }) => api<{ item: Item }>(`/api/v1/items/${id}`, { method: "PATCH", json: patch }), - onSuccess: ({ item }) => { - qc.setQueryData(keys.item(item.id), (old) => (old ? { ...old, item } : old)); - void qc.invalidateQueries({ queryKey: ["items"] }); - void qc.invalidateQueries({ queryKey: ["counts"] }); - void qc.invalidateQueries({ queryKey: keys.lists }); - }, + mutationFn: ({ id, patch }: { id: string; patch: UpdateItemBody }) => library.edit(id, patch), + onSuccess: (item) => qc.setQueryData(keys.item(item.id), (old) => (old ? { ...old, item } : old)), }); } diff --git a/apps/web/app/(app)/archive/ArchiveView.tsx b/apps/web/app/(app)/archive/ArchiveView.tsx index db2548a..f043c06 100644 --- a/apps/web/app/(app)/archive/ArchiveView.tsx +++ b/apps/web/app/(app)/archive/ArchiveView.tsx @@ -9,7 +9,7 @@ import { ItemRow } from "@/components/ItemRow"; import { useToast } from "@/components/Toast"; import { TopBar } from "@/components/TopBar"; import { useFormat } from "@/components/useFormat"; -import { moveItems } from "@/lib/moves"; +import { library, moveItems, went } from "@/lib/actions"; import styles from "./archive.module.css"; import { useNow } from "@/lib/use-now"; @@ -73,13 +73,13 @@ export function ArchiveView({ initial, bin, expiryDays }: { initial: ItemPage; b } async function restore(item: Item) { - const ok = bin ? (await fetch(`/api/v1/items/${item.id}/restore`, { method: "POST" })).ok : await moveItems([item.id], "inbox"); + const ok = bin ? await went(library.fromBin(item.id)) : await moveItems([item.id], "inbox"); if (!ok) return showToast({ message: app("common.didNotGoThrough") }); setRows((cur) => cur.filter((r) => r.id !== item.id)); showToast({ message: bin ? t("outOfBin") : app("moved.inbox"), undo: bin - ? () => void fetch(`/api/v1/items/${item.id}`, { method: "DELETE" }).then(() => load(filter)) + ? () => void went(library.remove(item.id)).then(() => load(filter)) : () => void moveItems([item.id], item.status).then(() => load(filter)), }); } diff --git a/apps/web/app/(app)/items/[id]/NoteEditor.tsx b/apps/web/app/(app)/items/[id]/NoteEditor.tsx index ad94098..ec56e63 100644 --- a/apps/web/app/(app)/items/[id]/NoteEditor.tsx +++ b/apps/web/app/(app)/items/[id]/NoteEditor.tsx @@ -7,6 +7,7 @@ import { DeleteButton, ItemBar } from "@/components/ItemBar"; import { ItemLists } from "@/components/ItemLists"; import { ItemTags } from "@/components/ItemTags"; import { useFormat } from "@/components/useFormat"; +import { library } from "@/lib/actions"; import styles from "./note.module.css"; type Patch = { title?: string; text?: string; kind?: TextKind; dueAt?: string | null }; @@ -30,12 +31,12 @@ export function NoteEditor({ item, text, tags, lists }: { item: Item; text: stri pending.current = {}; if (!Object.keys(patch).length) return; setState("saving"); - const res = await fetch(`/api/v1/items/${item.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch) }); - if (!res.ok) { + const saved = await library.edit(item.id, patch).catch(() => null); + if (!saved) { pending.current = { ...patch, ...pending.current }; return setState("error"); } - setEdited(((await res.json()) as { item: Item }).item.updatedAt); + setEdited(saved.updatedAt); setState(Object.keys(pending.current).length ? "saving" : "saved"); } diff --git a/apps/web/app/(app)/items/[id]/Preview.tsx b/apps/web/app/(app)/items/[id]/Preview.tsx index 4cab8b9..bb35591 100644 --- a/apps/web/app/(app)/items/[id]/Preview.tsx +++ b/apps/web/app/(app)/items/[id]/Preview.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { Player } from "@/components/Player"; import { useFormat } from "@/components/useFormat"; +import { library, went } from "@/lib/actions"; import { coverSrc } from "@/lib/format"; import styles from "./reader.module.css"; @@ -68,7 +69,7 @@ export function PreviewSwitch({ item }: { item: Item }) { return (