johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<script lang="ts">
import type * as defs from '#lib/lexicons/dev/knotless/defs.ts';
import type * as deleteTheme from '#lib/lexicons/dev/knotless/deleteTheme.ts';
import type * as getPreferences from '#lib/lexicons/dev/knotless/getPreferences.ts';
import { untrack } from 'svelte';
import { call } from '#lib/data.ts';
import HalfTag from '#lib/atoms/HalfTag.svelte';
import Icon from '#lib/atoms/Icon.svelte';
import ActionMenu from '#lib/molecules/ActionMenu.svelte';
import Empty from '#lib/molecules/Empty.svelte';
import ThemeCard from '#lib/molecules/ThemeCard.svelte';
import {
ATMOSPHERE,
MORE,
PLAIN,
choose,
chooseBoth,
drawnAs,
paletteCookie,
styleOf,
type Chosen,
type Palette,
type Theme
} from '#lib/theme.ts';
import { APPEARANCE_PREF, THEMES_PREF, putPrefs } from '#lib/preferences.ts';
import ThemeBrowser, { THEME_BROWSER } from '#lib/organisms/ThemeBrowser.svelte';
/**
* The colours the pages are drawn in: one theme, or a pair, one by day
* and one at night, as the system's light or dark says. Each is light
* or dark always, a theme after an editor's or an app's, or one of the
* viewer's own, `palette` (and `nightPalette`). Each choice is drawn in
* its own colours, and taken at once: in this browser, and kept with
* the account, for the next browser it logs in to. Its own are `mine`,
* the themes it published, each to delete, and `added`, those people
* made that it added from the dialog of them all, each to remove: a
* copy, as choosing one copies its colours, so the pages stay as chosen
* whatever its maker does with it after.
*/
let {
theme,
palette,
night: nightTheme,
nightPalette,
mine,
added,
makers
}: {
theme: string | null;
palette: Palette | null;
night: string | null;
nightPalette: Palette | null;
mine: defs.ThemeView[];
added: getPreferences.AddedTheme[];
makers: Record<string, string>;
} = $props();
let kept = $state<defs.ThemeView[]>([]);
let collected = $state<getPreferences.AddedTheme[]>([]);
/** Who made each added theme still there, by its URI, as the appview says. */
let made = $state<Record<string, string>>({});
$effect.pre(() => {
kept = mine;
collected = added;
made = makers;
});
/** Those added that are not the viewer's own, which `kept` lists. */
const others = $derived(collected.filter((a) => !kept.some((t) => t.uri === a.uri)));
/** A theme as its cookie names it, and its palette for one of the viewer's own. */
const from = (id: string | null, p: Palette | null): Chosen | null =>
id?.startsWith('custom-')
? p
? { theme: 'custom', palette: p }
: null
: id
? { theme: id, palette: null }
: null;
const LIGHT: Chosen = { theme: 'light', palette: null };
const DARK: Chosen = { theme: 'dark', palette: null };
// As the page came: what is chosen after is this one's to keep.
const alone = untrack(() => from(theme, palette));
const paired = untrack(() => from(nightTheme, nightPalette));
// None chosen is the system's: light by day, dark at night.
let both = $state(!alone || !!paired);
let day = $state<Chosen>(alone ?? LIGHT);
let night = $state<Chosen>(paired ?? DARK);
/** The half a choice is for, while there are two. */
let half = $state<'day' | 'night'>('day');
let problem = $state('');
const current = $derived(!both || half === 'day' ? day : night);
/** Which half a choice is for, said while there are two. */
const choosing = $derived(
half === 'day'
? 'Choose your day theme, for while your device is light.'
: 'Choose your night theme, for while your device is dark.'
);
const same = (a: Palette | null, b: Palette) => !!a && paletteCookie(a) === paletteCookie(b);
const isTheme = (c: Chosen, id: string) => !c.palette && c.theme === id;
const NAMES = Object.fromEntries([...PLAIN, ...MORE, ...ATMOSPHERE].map((t) => [t.id, t.name]));
/** What `c` is called: its theme's name, one of the viewer's own themes' name, or that it is their own. */
const named = (c: Chosen) =>
c.palette
? ([...kept, ...collected].find((t) => same(c.palette, t.palette))?.name ?? 'Your own')
: (NAMES[c.theme] ?? c.theme);
/** What the pages keep of `c`, as appearancePref and its night say it. */
const pref = (c: Chosen) =>
c.palette
? {
theme: 'custom',
palette: c.palette as defs.ThemeView['palette'],
...(c.source ? { source: c.source as defs.ThemeView['uri'] } : {})
}
: { theme: c.theme };
async function save() {
problem = '';
const system = both && isTheme(day, 'light') && isTheme(night, 'dark');
if (system) choose(null);
else if (both) chooseBoth(day, night);
else choose(day.palette ? null : day.theme, day.palette);
try {
const kept = system ? {} : both ? { ...pref(day), night: pref(night) } : pref(day);
await putPrefs([{ $type: APPEARANCE_PREF, ...kept }]);
} catch (err) {
problem = `Kept in this browser only: ${(err as Error).message}`;
}
}
/** Chooses `c`, for the half being chosen while there are two. */
function pick(c: Chosen) {
if (both && half === 'night') night = c;
else day = c;
save();
}
function mode(two: boolean) {
both = two;
half = 'day';
if (two && isTheme(day, 'dark')) day = LIGHT;
save();
}
/** Deletes `t`, one of the viewer's own, once they say so; the pages stay as they are if it is in use, as it was copied. */
async function remove(t: defs.ThemeView) {
if (!confirm(`Delete ${t.name} for good?`)) return;
problem = '';
try {
await call<typeof deleteTheme.main>('dev.knotless.deleteTheme', { uri: t.uri });
kept = kept.filter((k) => k.uri !== t.uri);
} catch (err) {
problem = (err as Error).message;
}
}
/** Adds `t` to the viewer's own themes, a copy, or takes it out again. */
const toggle = (t: defs.ThemeView) => {
made[t.uri] = t.creator.handle;
return keep(
collected.some((a) => a.uri === t.uri)
? collected.filter((a) => a.uri !== t.uri)
: [
{
uri: t.uri,
name: t.name,
creator: t.creator.did,
palette: t.palette,
addedAt: new Date().toISOString() as getPreferences.AddedTheme['addedAt']
},
...collected
]
);
};
/** Keeps `themes` as those the viewer added, as they are now unless the appview will not. */
async function keep(themes: getPreferences.AddedTheme[]) {
problem = '';
const before = collected;
collected = themes;
try {
await putPrefs([{ $type: THEMES_PREF, themes: collected }]);
} catch (err) {
collected = before;
problem = (err as Error).message;
}
}
/** Which halves `c` draws while there are two, or none. */
const tag = (c: Chosen | ((x: Chosen) => boolean)) => {
if (!both) return undefined;
const is =
typeof c === 'function'
? c
: (x: Chosen) =>
drawnAs(x) === drawnAs(c) && (!x.palette || same(x.palette, c.palette as Palette));
return is(day) || is(night) ? { day: is(day), night: is(night) } : undefined;
};
/** The viewer's own palettes in use that are none of their published themes, each once. */
const unlisted = $derived(
[day, ...(both ? [night] : [])]
.filter((c) => c.palette && ![...kept, ...collected].some((t) => same(c.palette, t.palette)))
.filter((c, i, all) => all.findIndex((o) => same(o.palette, c.palette as Palette)) === i)
);
/** A theme of the viewer's own: one it made, one it added, or a palette in use that is neither. */
type Own = {
key: string;
at: string | null;
name: string;
palette: Palette;
uri?: string;
by?: string;
made?: defs.ThemeView;
chosen?: Chosen;
};
/**
* Its own themes, as one list, the latest first: those made by when
* they were made, those added by when they were added (any from before
* that was kept, last), and a palette in use that is neither, first.
*/
const yours = $derived(
[
...kept.map((t): Own => ({
key: t.uri,
at: t.createdAt,
name: t.name,
palette: t.palette,
uri: t.uri,
by: t.creator.handle,
made: t
})),
...others.map((a): Own => ({
key: a.uri,
at: a.addedAt ?? '',
name: a.name ?? 'Untitled',
palette: a.palette,
uri: a.uri,
by: made[a.uri]
})),
...unlisted.map((c): Own => ({
key: paletteCookie(c.palette as Palette),
at: null,
name: 'Your own',
palette: c.palette as Palette,
chosen: c
}))
].sort((x, y) => (x.at === null ? -1 : y.at === null ? 1 : y.at.localeCompare(x.at)))
);
</script>
{#snippet preview(id: string, style?: string)}
<span class="theme-preview" data-theme={id} {style} aria-hidden="true">
<span class="theme-line"></span>
<span class="theme-line short"></span>
<span class="theme-dots"
><span class="theme-dot primary"></span><span class="theme-dot accent"></span><span
class="theme-dot green"
></span><span class="theme-dot red"></span></span
>
</span>
{/snippet}
{#snippet choice(t: Theme)}
<label class="theme-choice">
{@render preview(t.id)}
<span class="theme-name"
><input
type="radio"
name="theme"
checked={isTheme(current, t.id)}
onchange={() => pick({ theme: t.id, palette: null })}
/>{t.name}</span
>
{@render halfTag(tag({ theme: t.id, palette: null }))}
</label>
{/snippet}
{#snippet halfTag(half: { day: boolean; night: boolean } | undefined)}
{#if half}<HalfTag {...half} />{/if}
{/snippet}
{#snippet halfButton(which: 'day' | 'night', c: Chosen)}
<button
class="theme-half"
type="button"
role="tab"
id={`theme-half-${which}`}
aria-selected={half === which}
aria-controls="theme-list"
onclick={() => (half = which)}
>
{@render preview(drawnAs(c), c.palette ? styleOf(c.palette) : undefined)}
<span class="theme-made"
><span class="mute theme-half-label"
><Icon name={which === 'day' ? 'sun' : 'moon'} />{which === 'day'
? 'Day theme'
: 'Night theme'}</span
><span class="clip" title={named(c)}>{named(c)}</span></span
>
</button>
{/snippet}
<div class="settings-section"><h2>Appearance</h2></div>
<div class="settings-form">
<p>Choose the colours knotless is drawn in, here and wherever you log in next.</p>
<fieldset class="field">
<legend class="field-label">Mode</legend>
<div class="choices">
<label class="check"
><input type="radio" name="mode" checked={!both} onchange={() => mode(false)} />One theme</label
>
<label class="check"
><input type="radio" name="mode" checked={both} onchange={() => mode(true)} />Sync with
system</label
>
</div>
{#if both}<p class="hint">
Your day theme while your device is light, and your night theme while it is dark.
</p>{/if}
</fieldset>
{#if both}<div class="theme-halves" role="tablist" aria-label="Theme to change">
{@render halfButton('day', day)}{@render halfButton('night', night)}
</div>{/if}
<div
class="theme-list"
id="theme-list"
role={both ? 'tabpanel' : undefined}
aria-labelledby={both ? `theme-half-${half}` : undefined}
>
{#if both}<p class="theme-for">{choosing}</p>{/if}
<fieldset class="field">
<legend class="field-label">Theme</legend>
<div class="themes">
{#each PLAIN as t (t.id)}{@render choice(t)}{/each}
</div>
</fieldset>
<fieldset class="field">
<legend class="field-label">More themes</legend>
<div class="themes">
{#each MORE as t (t.id)}{@render choice(t)}{/each}
</div>
</fieldset>
<fieldset class="field">
<legend class="field-label">From the Atmosphere</legend>
<div class="themes">
{#each ATMOSPHERE as t (t.id)}{@render choice(t)}{/each}
</div>
</fieldset>
<fieldset class="field">
<legend class="field-label">Your themes</legend>
<p class="hint">Themes you made or added.</p>
{#if !yours.length}
<div class="box">
<Empty title="No themes yet">Add themes people made, or make your own.</Empty>
</div>
{:else}
<div class="themes">
{#each yours as o (o.key)}
{@const chosen = o.chosen ?? { theme: 'custom', palette: o.palette, source: o.uri }}
<ThemeCard
theme={{
name: o.name,
palette: o.palette,
creator: o.by ? { handle: o.by } : undefined
}}
checked={same(current.palette, o.palette)}
pick={() => pick(chosen)}
half={tag((c) => same(c.palette, o.palette))}
>
{#snippet menu()}
{#if o.made}<ActionMenu
label={`More for ${o.name}`}
actions={[
{ label: 'Delete', run: () => remove(o.made as defs.ThemeView), danger: true }
]}
/>
{:else if o.uri}<ActionMenu
label={`More for ${o.name}`}
actions={[
{ label: 'Remove', run: () => keep(collected.filter((a) => a.uri !== o.uri)) }
]}
/>{/if}
{/snippet}
</ThemeCard>
{/each}
</div>
{/if}
<p class="theme-actions">
<button class="btn" type="button" popovertarget={THEME_BROWSER}>Browse themes</button><a
class="btn"
href="/settings/appearance/new">Make a theme</a
>
</p>
</fieldset>
</div>
{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
</div>
<ThemeBrowser
has={(t) =>
kept.some((k) => k.uri === t.uri)
? 'mine'
: collected.some((a) => a.uri === t.uri)
? 'added'
: null}
{toggle}
/>