replies timeline only, appview-less bluesky client

refactor: separate into popup and tabs components for reusing

ptr.pet 6981b810 0183e9c5

verified
+42 -81
src/components/AccountSelector.svelte
···
import type { Handle } from '@atcute/lexicons';
import ProfilePicture from './ProfilePicture.svelte';
import PfpPlaceholder from './PfpPlaceholder.svelte';
import { flow } from '$lib/at/oauth';
import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
import Icon from '@iconify/svelte';
···
isDropdownOpen = false;
loginHandle = '';
loginError = '';
};
const closeLoginModal = () => {
isLoginModalOpen = false;
loginHandle = '';
loginError = '';
···
};
const handleKeydown = (event: KeyboardEvent) => {
-
if (event.key === 'Escape') {
-
closeLoginModal();
-
} else if (event.key === 'Enter' && !isLoggingIn) {
-
handleLogin();
-
}
};
const closeDropdown = () => {
···
{/if}
</div>
-
{#if isLoginModalOpen}
-
<div
-
class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm"
-
onclick={closeLoginModal}
-
onkeydown={handleKeydown}
-
role="button"
-
tabindex="-1"
-
>
-
<!-- svelte-ignore a11y_interactive_supports_focus -->
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
-
<div
-
class="w-full max-w-md animate-fade-in-scale rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) p-4 shadow-2xl transition-all"
-
onclick={(e) => e.stopPropagation()}
-
role="dialog"
-
>
-
<div class="mb-6 flex items-center justify-between">
-
<div>
-
<h2 class="text-2xl font-bold">add account</h2>
-
<div class="mt-2 flex gap-2">
-
<div class="h-1 w-10 rounded-full bg-(--nucleus-accent)"></div>
-
<div class="h-1 w-9 rounded-full bg-(--nucleus-accent2)"></div>
-
</div>
-
</div>
-
<!-- svelte-ignore a11y_consider_explicit_label -->
-
<button
-
onclick={closeLoginModal}
-
class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)"
-
>
-
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-
<path
-
stroke-linecap="round"
-
stroke-linejoin="round"
-
stroke-width="2.5"
-
d="M6 18L18 6M6 6l12 12"
-
/>
-
</svg>
-
</button>
</div>
-
<div class="space-y-5">
-
<div>
-
<label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
-
handle
-
</label>
-
<input
-
id="handle"
-
type="text"
-
bind:value={loginHandle}
-
placeholder="example.bsky.social"
-
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
-
disabled={isLoggingIn}
-
/>
-
</div>
-
-
{#if loginError}
-
<div class="error-disclaimer">
-
<p>
-
<Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
-
{loginError}
-
</p>
-
</div>
-
{/if}
-
-
<div class="flex gap-3 pt-3">
-
<button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}>
-
cancel
-
</button>
-
<button
-
onclick={handleLogin}
-
class="flex-1 action-button border-transparent text-(--nucleus-fg)"
-
style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));"
-
disabled={isLoggingIn}
-
>
-
{isLoggingIn ? 'logging in...' : 'login'}
-
</button>
-
</div>
-
</div>
</div>
</div>
-
{/if}
···
import type { Handle } from '@atcute/lexicons';
import ProfilePicture from './ProfilePicture.svelte';
import PfpPlaceholder from './PfpPlaceholder.svelte';
+
import Popup from './Popup.svelte';
import { flow } from '$lib/at/oauth';
import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
import Icon from '@iconify/svelte';
···
isDropdownOpen = false;
loginHandle = '';
loginError = '';
+
// HACK: i hate this but it works so it doesnt really matter
+
setTimeout(() => document.getElementById('handle')?.focus(), 100);
};
const closeLoginModal = () => {
+
document.getElementById('handle')?.blur();
isLoginModalOpen = false;
loginHandle = '';
loginError = '';
···
};
const handleKeydown = (event: KeyboardEvent) => {
+
if (event.key === 'Enter' && !isLoggingIn) handleLogin();
};
const closeDropdown = () => {
···
{/if}
</div>
+
<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account">
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div class="space-y-2" onkeydown={handleKeydown}>
+
<div>
+
<label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
+
account handle
+
</label>
+
<input
+
id="handle"
+
type="text"
+
bind:value={loginHandle}
+
placeholder="example.bsky.social"
+
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
+
disabled={isLoggingIn}
+
/>
+
</div>
+
+
{#if loginError}
+
<div class="error-disclaimer">
+
<p>
+
<Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
+
{loginError}
+
</p>
</div>
+
{/if}
+
<div class="flex gap-3 pt-3">
+
<button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}>
+
cancel
+
</button>
+
<button
+
onclick={handleLogin}
+
class="flex-1 action-button border-transparent text-(--nucleus-fg)"
+
style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));"
+
disabled={isLoggingIn}
+
>
+
{isLoggingIn ? 'logging in...' : 'login'}
+
</button>
</div>
</div>
+
</Popup>
+102
src/components/Popup.svelte
···
···
+
<script lang="ts">
+
import type { Snippet } from 'svelte';
+
+
interface Props {
+
isOpen: boolean;
+
onClose?: () => void;
+
title: string;
+
width?: string;
+
height?: string;
+
padding?: string;
+
showHeaderDivider?: boolean;
+
headerActions?: Snippet;
+
children: Snippet;
+
footer?: Snippet;
+
}
+
+
let {
+
isOpen = $bindable(false),
+
onClose = () => (isOpen = false),
+
title,
+
width = 'w-full max-w-md',
+
height = 'auto',
+
padding = 'p-4',
+
showHeaderDivider = false,
+
headerActions,
+
children,
+
footer
+
}: Props = $props();
+
+
const handleKeydown = (event: KeyboardEvent) => {
+
if (event.key === 'Escape') onClose();
+
};
+
</script>
+
+
{#if isOpen}
+
<div
+
class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm"
+
onclick={onClose}
+
onkeydown={handleKeydown}
+
role="button"
+
tabindex="-1"
+
>
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
class="flex {height === 'auto'
+
? ''
+
: 'h-[' +
+
height +
+
']'} {width} shrink animate-fade-in-scale flex-col rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all"
+
style={height !== 'auto' ? `height: ${height}` : ''}
+
onclick={(e) => e.stopPropagation()}
+
role="dialog"
+
>
+
<!-- Header -->
+
<div
+
class="flex items-center gap-4 {showHeaderDivider
+
? 'border-b-2 border-(--nucleus-accent)/20'
+
: ''} {padding}"
+
>
+
<div>
+
<h2 class="text-2xl font-bold">{title}</h2>
+
<div class="mt-2 flex gap-2">
+
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
+
<div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div>
+
</div>
+
</div>
+
+
{#if headerActions}
+
{@render headerActions()}
+
{/if}
+
+
<div class="grow"></div>
+
+
<!-- svelte-ignore a11y_consider_explicit_label -->
+
<button
+
onclick={onClose}
+
class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)"
+
>
+
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2.5"
+
d="M6 18L18 6M6 6l12 12"
+
/>
+
</svg>
+
</button>
+
</div>
+
+
<!-- Content -->
+
<div class="{height === 'auto' ? '' : 'flex-1 overflow-y-auto'} {padding}">
+
{@render children()}
+
</div>
+
+
<!-- Footer -->
+
{#if footer}
+
{@render footer()}
+
{/if}
+
</div>
+
</div>
+
{/if}
+35 -83
src/components/SettingsPopup.svelte
···
import { handleCache, didDocCache, recordCache } from '$lib/at/client';
import { get } from 'svelte/store';
import ColorPicker from 'svelte-awesome-color-picker';
interface Props {
isOpen: boolean;
···
let { isOpen = $bindable(false), onClose }: Props = $props();
-
type Tab = 'advanced' | 'moderation' | 'style';
let activeTab = $state<Tab>('advanced');
let localSettings = $state(get(settings));
···
const handleSave = () => {
settings.set(localSettings);
-
// reload to update api endpoints
window.location.reload();
};
···
recordCache.clear();
alert('cache cleared!');
};
-
-
const handleKeydown = (event: KeyboardEvent) => {
-
if (event.key === 'Escape') handleClose();
-
};
</script>
{#snippet divider()}
···
<label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
{desc}
</label>
-
<!-- todo: add validation for url -->
<input
id={name}
type="url"
···
</div>
{/snippet}
-
{#if isOpen}
-
<div
-
class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 p-12 backdrop-blur-sm"
-
onclick={handleClose}
-
onkeydown={handleKeydown}
-
role="button"
-
tabindex="-1"
-
>
-
<!-- svelte-ignore a11y_interactive_supports_focus -->
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
-
<div
-
class="flex h-[60vh] w-[42vmax] max-w-2xl shrink animate-fade-in-scale flex-col rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all"
-
onclick={(e) => e.stopPropagation()}
-
role="dialog"
-
>
-
<div class="flex items-center gap-4 border-b-2 border-(--nucleus-accent)/20 p-4">
-
<div>
-
<h2 class="text-2xl font-bold">settings</h2>
-
<div class="mt-2 flex gap-2">
-
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
-
<div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div>
-
</div>
-
</div>
-
{#if hasReloadChanges}
-
<button onclick={handleSave} class="shrink-0 action-button px-6"> save & reload </button>
-
{/if}
-
<div class="grow"></div>
-
<!-- svelte-ignore a11y_consider_explicit_label -->
-
<button
-
onclick={handleClose}
-
class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110"
-
>
-
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-
<path
-
stroke-linecap="round"
-
stroke-linejoin="round"
-
stroke-width="2.5"
-
d="M6 18L18 6M6 6l12 12"
-
/>
-
</svg>
-
</button>
-
</div>
-
-
<div class="flex-1 overflow-y-auto p-4">
-
{#if activeTab === 'advanced'}
-
{@render advancedTab()}
-
{:else if activeTab === 'moderation'}
-
<div class="flex h-full items-center justify-center">
-
<div class="text-center">
-
<div class="mb-4 text-6xl opacity-50">🚧</div>
-
<h3 class="text-xl font-bold opacity-80">todo</h3>
-
</div>
-
</div>
-
{:else if activeTab === 'style'}
-
{@render styleTab()}
-
{/if}
-
</div>
-
<div>
-
<div class="flex">
-
{#snippet tabButton(name: Tab)}
-
{@const isActive = activeTab === name}
-
<button
-
onclick={() => (activeTab = name)}
-
class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive
-
? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)'
-
: 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}"
-
>
-
{name}
-
</button>
-
{/snippet}
-
{#each ['style', 'moderation', 'advanced'] as Tab[] as tabName (tabName)}
-
{@render tabButton(tabName)}
-
{/each}
-
</div>
</div>
</div>
-
</div>
-
{/if}
···
import { handleCache, didDocCache, recordCache } from '$lib/at/client';
import { get } from 'svelte/store';
import ColorPicker from 'svelte-awesome-color-picker';
+
import Popup from './Popup.svelte';
+
import Tabs from './Tabs.svelte';
interface Props {
isOpen: boolean;
···
let { isOpen = $bindable(false), onClose }: Props = $props();
+
type Tab = 'style' | 'moderation' | 'advanced';
let activeTab = $state<Tab>('advanced');
let localSettings = $state(get(settings));
···
const handleSave = () => {
settings.set(localSettings);
window.location.reload();
};
···
recordCache.clear();
alert('cache cleared!');
};
</script>
{#snippet divider()}
···
<label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
{desc}
</label>
<input
id={name}
type="url"
···
</div>
{/snippet}
+
<Popup
+
bind:isOpen
+
onClose={handleClose}
+
title="settings"
+
width="w-[42vmax] max-w-2xl"
+
height="60vh"
+
showHeaderDivider={true}
+
>
+
{#snippet headerActions()}
+
{#if hasReloadChanges}
+
<button onclick={handleSave} class="shrink-0 action-button"> save & reload </button>
+
{/if}
+
{/snippet}
+
{#if activeTab === 'advanced'}
+
{@render advancedTab()}
+
{:else if activeTab === 'moderation'}
+
<div class="flex h-full items-center justify-center">
+
<div class="text-center">
+
<div class="mb-4 text-6xl opacity-50">🚧</div>
+
<h3 class="text-xl font-bold opacity-80">todo</h3>
</div>
</div>
+
{:else if activeTab === 'style'}
+
{@render styleTab()}
+
{/if}
+
+
{#snippet footer()}
+
<Tabs
+
tabs={['style', 'moderation', 'advanced']}
+
bind:activeTab
+
onTabChange={(tab) => (activeTab = tab)}
+
/>
+
{/snippet}
+
</Popup>
+23
src/components/Tabs.svelte
···
···
+
<script lang="ts" generics="T extends string">
+
interface Props {
+
tabs: T[];
+
activeTab: T;
+
onTabChange: (tab: T) => void;
+
}
+
+
let { tabs, activeTab = $bindable(), onTabChange }: Props = $props();
+
</script>
+
+
<div class="flex">
+
{#each tabs as tab (tab)}
+
{@const isActive = activeTab === tab}
+
<button
+
onclick={() => onTabChange(tab)}
+
class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive
+
? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)'
+
: 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}"
+
>
+
{tab}
+
</button>
+
{/each}
+
</div>