Skip to content

Commit

Permalink
Improve handling of localStorage and svelte stores
Browse files Browse the repository at this point in the history
Also moving the svelte-stored-writable package into the repo.
So we have it closer for maintenance, we can eventually rewrite it in
svelte 5 runes later.

Reference: efstajas/svelte-stored-writable#5
  • Loading branch information
sebastinez committed Nov 25, 2024
1 parent bd02c85 commit 023c0ec
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 87 deletions.
10 changes: 0 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
"wait-on": "^8.0.1"
},
"dependencies": {
"@efstajas/svelte-stored-writable": "^0.3.0",
"@radicle/gray-matter": "4.1.0",
"@wooorm/starry-night": "^3.5.0",
"async-mutex": "^0.5.0",
Expand Down
2 changes: 1 addition & 1 deletion src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import * as router from "@app/lib/router";
import { unreachable } from "@app/lib/utils";
import { codeFont, theme } from "@app/lib/appearance";
import { codeFont, followSystemTheme, theme } from "@app/lib/appearance";
import FullscreenModalPortal from "./App/FullscreenModalPortal.svelte";
import Hotkeys from "./App/Hotkeys.svelte";
Expand Down
17 changes: 10 additions & 7 deletions src/App/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@
import {
codeFont,
codeFonts,
storeCodeFont,
storeTheme,
theme,
followSystemTheme,
} from "@app/lib/appearance";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import Radio from "@app/components/Radio.svelte";
</script>

<style>
Expand Down Expand Up @@ -48,7 +45,10 @@
variant={!$followSystemTheme && $theme === "light"
? "selected"
: "not-selected"}
on:click={() => storeTheme("light")}>
on:click={() => {
theme.set("light");
followSystemTheme.set(false);
}}>
<Icon name="sun" />
</Button>
<div class="global-spacer" />
Expand All @@ -58,15 +58,18 @@
variant={!$followSystemTheme && $theme === "dark"
? "selected"
: "not-selected"}
on:click={() => storeTheme("dark")}>
on:click={() => {
theme.set("dark");
followSystemTheme.set(false);
}}>
<Icon name="moon" />
</Button>
<div class="global-spacer" />
<Button
ariaLabel="System Theme"
styleBorderRadius="0"
variant={$followSystemTheme ? "selected" : "not-selected"}
on:click={() => storeTheme("system")}>
on:click={() => followSystemTheme.set(true)}>
<Icon name="device" />
</Button>
</Radio>
Expand All @@ -80,7 +83,7 @@
<Button
styleBorderRadius="0"
styleFontFamily={font.fontFamily}
on:click={() => storeCodeFont(font.storedName)}
on:click={() => codeFont.set(font.storedName)}
variant={$codeFont === font.storedName
? "selected"
: "not-selected"}>
Expand Down
99 changes: 32 additions & 67 deletions src/lib/appearance.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
import { writable } from "svelte/store";
import storedWritable from "@app/lib/localStore";
import { boolean, literal, union, z } from "zod";

const themeSchema = union([literal("dark"), literal("light")]);
type Theme = z.infer<typeof themeSchema>;

export const followSystemTheme = storedWritable<boolean | undefined>(
"followSystemTheme",
boolean(),
!localStorage.getItem("theme"),
!window.localStorage,
);
export const theme = storedWritable<Theme>(
"theme",
themeSchema,
loadTheme(),
!window.localStorage,
);

export type Theme = "dark" | "light";
export const followSystemTheme = writable<boolean>(shouldFollowSystemTheme());
export const theme = writable<Theme>(loadTheme());
function loadTheme(): Theme {
const { matches } = window.matchMedia("(prefers-color-scheme: dark)");

return matches ? "dark" : "light";
}

const codeFontSchema = union([literal("jetbrains"), literal("system")]);
type CodeFont = z.infer<typeof codeFontSchema>;

export type CodeFont = "jetbrains" | "system";
export const codeFont = writable<CodeFont>(loadCodeFont());
export const codeFont = storedWritable(
"codefont",
codeFontSchema,
"jetbrains",
!window.localStorage,
);

export const codeFonts: {
storedName: CodeFont;
Expand All @@ -19,64 +45,3 @@ export const codeFonts: {
},
{ storedName: "system", fontFamily: "monospace", displayName: "System" },
];

function loadCodeFont(): CodeFont {
const storedCodeFont = localStorage ? localStorage.getItem("codefont") : null;

if (storedCodeFont === null) {
return "jetbrains";
} else {
return storedCodeFont as CodeFont;
}
}

function shouldFollowSystemTheme(): boolean {
const storedTheme = localStorage ? localStorage.getItem("theme") : null;
if (storedTheme === null) {
return true; // default to following the system theme
} else {
return storedTheme === "system";
}
}

function loadTheme(): Theme {
const { matches } = window.matchMedia("(prefers-color-scheme: dark)");
const storedTheme = localStorage ? localStorage.getItem("theme") : null;

if (storedTheme === null || storedTheme === "system") {
return matches ? "dark" : "light";
} else {
return storedTheme as Theme;
}
}

export function storeTheme(newTheme: Theme | "system"): void {
followSystemTheme.set(newTheme === "system" ? true : false);
if (localStorage) {
localStorage.setItem("theme", newTheme);
} else {
console.warn(
"localStorage isn't available, not able to persist the selected theme without it.",
);
}
if (newTheme !== "system") {
// update the theme to newTheme
theme.set(newTheme);
} else {
// update the theme to the current system theme
theme.set(
window.matchMedia("(prefers-color-scheme: dark)") ? "dark" : "light",
);
}
}

export function storeCodeFont(newCodeFont: CodeFont): void {
codeFont.set(newCodeFont);
if (localStorage) {
localStorage.setItem("codefont", newCodeFont);
} else {
console.warn(
"localStorage isn't available, not able to persist the selected code font without it.",
);
}
}
104 changes: 104 additions & 0 deletions src/lib/localStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { SafeParseReturnType } from "zod";
import type { Writable } from "svelte/store";

import { writable, get } from "svelte/store";
import { z } from "zod";

type Equals<X, Y> =
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
? true
: false;

/**
* An extension of Svelte's `writable` that also saves its state to localStorage and
* automatically restores it.
* @param key The localStorage key to use for saving the writable's contents.
* @param schema A Zod schema describing the contents of the writable.
* @param initialValue The initial value to use if no prior state has been saved in
* localstorage.
* @param disableLocalStorage Skip interaction with localStorage, for example during SSR.
* @returns A stored writable.
*/
export default function storedWritable<
S extends z.infer<T>,
T extends z.ZodType = z.ZodType<S>,
>(
key: string,
schema: T,
initialValue: z.infer<typeof schema>,
disableLocalStorage = false,
): Writable<
Equals<T, typeof schema> extends true ? S : z.infer<typeof schema>
> & { clear: () => void } {
const stored = !disableLocalStorage ? localStorage.getItem(key) : null;

const parseFromJson = (
content: string,
): SafeParseReturnType<string, T["_output"]> => {
return z
.string()
.transform((_, ctx) => {
try {
return JSON.parse(content);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "invalid json",
});
return z.never;
}
})
.pipe(schema)
.safeParse(content);
};

// Subscribe to window storage event to keep changes from another tab in sync.
if (!disableLocalStorage) {
window?.addEventListener("storage", event => {
if (event.key === key) {
if (event.newValue === null) {
w.set(initialValue);
return;
}

const { success, data } = parseFromJson(event.newValue);
w.set(success ? data : initialValue);
}
});
}
const parsed = parseFromJson(stored || initialValue);
const w = writable<S>(parsed?.success ? parsed.data : initialValue);

/**
* Set writable value and inform subscribers. Updates the writeable's stored data in
* localstorage.
* */
function set(...args: Parameters<typeof w.set>) {
w.set(...args);
if (!disableLocalStorage) localStorage.setItem(key, JSON.stringify(get(w)));
}

/**
* Update writable value using a callback and inform subscribers. Updates the writeable's
* stored data in localstorage.
* */
function update(...args: Parameters<typeof w.update>) {
w.update(...args);
if (!disableLocalStorage) localStorage.setItem(key, JSON.stringify(get(w)));
}

/**
* Delete any data saved for this StoredWritable in localstorage.
*/
function clear() {
w.set(initialValue);
localStorage.removeItem(key);
}

return {
subscribe: w.subscribe,
set,
update,
clear,
};
}
2 changes: 1 addition & 1 deletion src/views/nodes/SeedSelector.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BaseUrl } from "@http-client";

import isEqual from "lodash/isEqual";
import storedWritable from "@efstajas/svelte-stored-writable";
import storedWritable from "@app/lib/localStore";
import { array, number, string, object } from "zod";
import { get } from "svelte/store";

Expand Down

0 comments on commit 023c0ec

Please sign in to comment.