+
@@ -164,8 +164,10 @@
font-size: 0.75rem;
transition: background-color 0.15s ease;
- &:hover {
- background-color: oklch(from var(--color-text) l c h / 20%);
+ @media (hover: hover) {
+ &:hover {
+ background-color: oklch(from var(--color-text) l c h / 20%);
+ }
}
}
diff --git a/src/lib/modules/market/model/market.api.ts b/src/lib/modules/market/model/market.api.ts
index 48bb3ab..ac5f2e4 100644
--- a/src/lib/modules/market/model/market.api.ts
+++ b/src/lib/modules/market/model/market.api.ts
@@ -20,6 +20,12 @@ export const loadMarketFx = createEffect(async () => {
return { items, likes };
});
+// one record for its own page (/market/[id]) — a deep link has no catalog list to read from,
+// and $items only ever holds published faces
+export const loadWfFx = createEffect((id: string) =>
+ pb.collection("watchfaces").getOne(id, { expand: "owner" }),
+);
+
export const loadMyFx = createEffect((userId: string) =>
pb.collection("watchfaces").getFullList({ sort: "-updated", filter: `owner = '${userId}'` }),
);
diff --git a/src/lib/modules/market/model/market.model.ts b/src/lib/modules/market/model/market.model.ts
index c2ff406..f447ffe 100644
--- a/src/lib/modules/market/model/market.model.ts
+++ b/src/lib/modules/market/model/market.model.ts
@@ -7,6 +7,13 @@ import { fileUrl } from "$lib/shared/api";
import { authModel } from "$lib/modules/auth/model";
import { bleModel } from "$lib/modules/device/model";
import { editorModel } from "$lib/modules/editor/model";
+// The watchface page draws the real dial rather than the still preview, so it needs the editor's
+// domain layer (the .bin reader and the renderer's inputs) — not its model, which is the editor's
+// own session. Everything the renderer itself needs stays in the component that draws.
+import { parseBin } from "$lib/modules/editor/core/format";
+import { fromLegacy, type Doc } from "$lib/modules/editor/core/document/doc";
+import { decodeAssets } from "$lib/modules/editor/core/render/pixels";
+import type { ImageStore } from "$lib/modules/editor/core/render/canvas";
import * as marketApi from "./market.api";
export type { SavePayload } from "./market.api";
@@ -33,9 +40,6 @@ export const $foreignWf = combine(
$openedWf,
(loaded, opened) => Boolean(loaded) && !opened,
);
-// editorModel.loadDone also fires for unrelated loads (drag-drop import on /editor) — only
-// navigate when the load we're waiting on is specifically the one editRequested started
-const $awaitingEdit = createStore(false);
// editor.svelte's "Save" and PublishDialog's "Publish" both hit saveFx but need different
// done/error handling (Publish also navigates + closes the dialog) and are mounted on the same
// page at the same time — a shared done/err reaction would make one react to the other's call,
@@ -46,6 +50,21 @@ export const $publishDialogOpen = createStore(false);
// marketLoadRequested fires on every market.svelte mount — load the catalog once per session,
// a page revisit reuses $items; reloading needs a full page refresh (or removeFx's own reload below)
const $marketRequestedOnce = createStore(false);
+// the watchface open on its own page (/market/[id]) — fetched by id, not read out of $items
+export const $wf = createStore(null);
+export const $wfLoading = marketApi.loadWfFx.pending;
+// the one being flashed straight from that page, without the editor — the downloads bump
+// below reads it first, $loadedWf only ever knows what the editor has open
+const $installingWf = createStore(null);
+export const $installed = createStore(false);
+/** The parsed face behind that page: what the canvas draws, ticking against the real clock. */
+export interface LiveFace {
+ doc: Doc;
+ store: ImageStore;
+}
+export const $live = createStore(null);
+// the same bytes an install sends — fetched once, whichever happens first
+const $bin = createStore(null);
export const $likes = createStore([]);
export const $items = createStore([]);
export const $myItems = createStore([]);
@@ -59,9 +78,12 @@ export const publishToggleRequested = createEvent();
export const openedWfSet = createEvent();
// fired by the component on New / drag-drop import — the loaded face has no backing record
export const faceDetached = createEvent();
-// "open in editor" from a market/my card: fetch the .bin, hand it to the editor model, then
-// navigate once it's actually loaded — used by both pages (market.svelte, my.svelte)
+// "open in editor" from a market/my card or the watchface page: go to the editor at once and
+// fetch the .bin behind it, handing the bytes to the editor model when they land
export const editRequested = createEvent();
+// the watchface page: load the record by id, and flash it to the watch without the editor
+export const wfLoadRequested = createEvent();
+export const installRequested = createEvent();
export const saveDraftRequested = createEvent();
export const publishRequested = createEvent();
export const publishDialogOpened = createEvent();
@@ -78,6 +100,33 @@ const openInEditorFx = createEffect(async (wf: RecordModel) => {
return { wf, buf };
});
const navigateToEditorFx = createEffect(() => goto("/editor"));
+const binOfFx = createEffect(
+ async (wf: RecordModel) => new Uint8Array(await (await fetch(fileUrl(wf, "bin"))).arrayBuffer()),
+);
+// the still preview is a fallback, not the plan: parse the file and hand the renderer the same
+// two things the editor gives it — the document and its decoded pixels
+const liveFx = createEffect(async (bin: Uint8Array): Promise => {
+ const { doc } = fromLegacy(parseBin(bin));
+
+ return { doc, store: { assets: doc.images, cache: await decodeAssets(doc.images) } };
+});
+// install from the watchface page: the .bin is all the watch needs, the editor never enters it
+const fetchBinFx = attach({
+ source: $bin,
+ async effect(cached, wf: RecordModel) {
+ return {
+ bin: cached ?? (await binOfFx(wf)),
+ // the watch reports ids only — the market preview is what makes it recognisable later
+ preview: fileUrl(wf, "preview"),
+ key: wf.id,
+ };
+ },
+});
+export const $installing = combine(
+ fetchBinFx.pending,
+ bleModel.$flashing,
+ (fetching, flashing) => fetching || flashing,
+);
// resolves openedId from $openedWf so the api layer doesn't need to know about model state
const saveFx = attach({
source: $openedWf,
@@ -145,12 +194,75 @@ sample({
sample({
clock: faceDetached,
fn: () => null,
- target: [$openedWf, $loadedWf, editorModel.faceKeySet],
+ target: [$openedWf, $loadedWf, $installingWf, editorModel.faceKeySet],
+});
+
+sample({
+ clock: wfLoadRequested,
+ target: marketApi.loadWfFx,
+});
+sample({
+ clock: marketApi.loadWfFx.doneData,
+ target: $wf,
+});
+// Coming from a list, the record is already in hand — show it (preview, name, author) on the
+// spot and let the fetch below refresh it. A deep link finds nothing cached and falls back to
+// the skeleton. Also what clears the previous face, so no reset() for $wf.
+sample({
+ clock: wfLoadRequested,
+ source: { items: $items, mine: $myItems },
+ fn: ({ items, mine }, id) =>
+ items.find((i) => i.id === id) ?? mine.find((i) => i.id === id) ?? null,
+ target: $wf,
+});
+// a different face on screen than the one that was just installed
+reset({ clock: wfLoadRequested, target: [$installed, $live, $bin] });
+
+// the page draws the dial for real: fetch the file, parse it, decode its pixels
+sample({
+ clock: marketApi.loadWfFx.doneData,
+ target: binOfFx,
+});
+sample({
+ clock: binOfFx.doneData,
+ target: [$bin, liveFx],
+});
+sample({
+ clock: liveFx.doneData,
+ target: $live,
+});
+// a file we can't parse or decode isn't worth an error banner — the still preview stays up,
+// and everything else on the page (install included) works off the bytes regardless
+
+sample({
+ clock: installRequested,
+ target: [fetchBinFx, $installingWf],
+});
+sample({
+ clock: fetchBinFx.doneData,
+ target: bleModel.flashRequested,
+});
+sample({
+ clock: bleModel.flashDone,
+ source: $installingWf,
+ filter: Boolean,
+ fn: () => true,
+ target: $installed,
+});
+reset({ clock: installRequested, target: $installed });
+// whichever of the two acted last owns the flash: opening a face in the editor hands the
+// downloads bump back to $loadedWf
+sample({
+ clock: openInEditorFx.doneData,
+ fn: () => null,
+ target: $installingWf,
});
+// Open the editor first and let it show its own loading state: the .bin is a megabyte over the
+// network, and waiting for it on the page the user just left off reads as a frozen site.
sample({
clock: editRequested,
- target: openInEditorFx,
+ target: [openInEditorFx, navigateToEditorFx, editorModel.bytesAwaited],
});
sample({
clock: openInEditorFx.failData,
@@ -176,23 +288,6 @@ sample({
target: editorModel.loadRequested,
});
-sample({
- clock: openInEditorFx.doneData,
- fn: () => true,
- target: $awaitingEdit,
-});
-sample({
- clock: editorModel.loadDone,
- source: $awaitingEdit,
- filter: Boolean,
- target: navigateToEditorFx,
-});
-sample({
- clock: editorModel.loadDone,
- fn: () => false,
- target: $awaitingEdit,
-});
-
sample({
clock: saveFx.doneData,
target: openedWfSet,
@@ -351,6 +446,8 @@ sample({
clock: [
marketApi.loadMarketFx.failData,
marketApi.loadMyFx.failData,
+ marketApi.loadWfFx.failData,
+ fetchBinFx.failData,
toggleLikeFx.failData,
marketApi.removeFx.failData,
marketApi.togglePublishFx.failData,
@@ -366,7 +463,7 @@ sample({
// downloads counter also bumps on a successful flash to the watch — no auth check, own or not
sample({
clock: bleModel.flashDone,
- source: $loadedWf,
+ source: combine($installingWf, $loadedWf, (installing, loaded) => installing || loaded),
filter: Boolean,
fn: (wf) => wf.id,
target: marketApi.bumpDownloadsFx,
@@ -383,6 +480,13 @@ sample({
fn: (list, { params: wfId }) => bumpDownloads(list, wfId),
target: $myItems,
});
+sample({
+ clock: marketApi.bumpDownloadsFx.done,
+ source: $wf,
+ filter: (wf, { params: wfId }) => wf?.id === wfId,
+ fn: (wf, _p) => ({ ...wf!, downloads: (wf!.downloads || 0) + 1 }),
+ target: $wf,
+});
// any successful load clears the banner — otherwise a one-off failure (or a request the SDK
// auto-cancelled) stayed on screen for the rest of the session, /my never reset it at all
diff --git a/src/lib/modules/market/pages/index.ts b/src/lib/modules/market/pages/index.ts
index 57c10d5..525670e 100644
--- a/src/lib/modules/market/pages/index.ts
+++ b/src/lib/modules/market/pages/index.ts
@@ -1,2 +1,3 @@
export { default as MarketPage } from "./market.svelte";
export { default as MyPage } from "./my.svelte";
+export { default as WatchfacePage } from "./watchface.svelte";
diff --git a/src/lib/modules/market/pages/market.svelte b/src/lib/modules/market/pages/market.svelte
index cef0f6f..503c888 100644
--- a/src/lib/modules/market/pages/market.svelte
+++ b/src/lib/modules/market/pages/market.svelte
@@ -4,6 +4,7 @@
import { Skeleton } from "$lib/shared/components/skeleton";
import { Icon } from "$lib/shared/components/icon";
import type { RecordModel } from "pocketbase";
+ import { goto } from "$app/navigation";
import { authModel } from "$lib/modules/auth/model";
import { marketModel } from "../model";
import { WatchfaceCard } from "../components/watchface-card";
@@ -17,7 +18,6 @@
marketLoadRequested,
likeToggleRequested,
removeRequested,
- editRequested,
} = marketModel;
marketLoadRequested();
@@ -113,7 +113,7 @@
liked={!!myLike(wf.id)}
canLike={!!$user}
canRemove={$user?.id === wf.owner}
- onOpen={() => editRequested(wf)}
+ onOpen={() => goto(`/market/${wf.id}`)}
onLike={() => $user && likeToggleRequested({ wf, userId: $user.id })}
onRemove={() => remove(wf)}
/>
@@ -167,12 +167,31 @@
.sort {
width: 8.125rem;
}
+ /* a phone can't fit both fixed widths, and wrapping left the search pinned right on a row
+ of its own — let it take whatever the sort doesn't */
+ @media (max-width: 767px) {
+ .toolbar {
+ flex-wrap: nowrap;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ }
+ .search {
+ flex: 1;
+ width: auto;
+ margin-inline-start: 0;
+ }
+ .sort {
+ width: 7.5rem;
+ flex: none;
+ }
+ }
main {
overflow-y: auto;
}
.grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(11.875rem, 1fr));
+ /* the min() keeps two columns on a phone, where a fixed 8.875rem would drop to one */
+ grid-template-columns: repeat(auto-fill, minmax(min(8.875rem, 50%), 1fr));
align-items: start;
gap: 1rem;
padding: 1rem;
@@ -181,6 +200,11 @@
min-height: auto;
height: 100%;
}
+
+ @media (max-width: 767px) {
+ gap: 0.5rem;
+ padding: 0.5rem;
+ }
}
.skeleton-card {
display: flex;
diff --git a/src/lib/modules/market/pages/my.svelte b/src/lib/modules/market/pages/my.svelte
index 5c27497..983fe44 100644
--- a/src/lib/modules/market/pages/my.svelte
+++ b/src/lib/modules/market/pages/my.svelte
@@ -46,7 +46,7 @@
{#each $myItems as wf (wf.id)}
-