~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
396 lines · 12.9 KB Raw History
<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}
/>