Compare commits
11 Commits
1d0ca31262
...
59b0d9c620
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b0d9c620 | ||
|
|
be13a5c8a0 | ||
|
|
80efa49ad0 | ||
|
|
7e9675be80 | ||
|
|
ac979c816c | ||
|
|
272c2c2d22 | ||
|
|
a9e2898945 | ||
|
|
1712134f64 | ||
|
|
52111ee941 | ||
|
|
e4970e43ba | ||
|
|
b41c48da67 |
@@ -117,6 +117,8 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Karla', system-ui, sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ let { children } = $props();
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,200..800;1,200..800&display=swap"
|
||||
rel="stylesheet"
|
||||
>
|
||||
</svelte:head>
|
||||
|
||||
<div id="app-root">
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
- Adds smooth transition when font appears
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { motion } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import { appliedFontsManager } from '../../model';
|
||||
|
||||
interface Props {
|
||||
@@ -59,7 +59,7 @@ const status = $derived(appliedFontsManager.getFontStatus(id, weight, false));
|
||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
||||
|
||||
const transitionClasses = $derived(
|
||||
motion.reduced
|
||||
prefersReducedMotion.current
|
||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
||||
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
);
|
||||
@@ -71,8 +71,9 @@ const transitionClasses = $derived(
|
||||
class={cn(
|
||||
transitionClasses,
|
||||
// If reduced motion is on, we skip the transform/blur entirely
|
||||
!shouldReveal && !motion.reduced && 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
|
||||
!shouldReveal && motion.reduced && 'opacity-0', // Still hide until font is ready, but no movement
|
||||
!shouldReveal && !prefersReducedMotion.current
|
||||
&& 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
|
||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Badge } from '$shared/shadcn/ui/badge';
|
||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import {
|
||||
type UnifiedFont,
|
||||
selectedFontsStore,
|
||||
@@ -17,32 +17,96 @@ interface Props {
|
||||
* Object with information about font
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Is element fully visible
|
||||
*/
|
||||
isVisible: boolean;
|
||||
/**
|
||||
* From 0 to 1
|
||||
*/
|
||||
proximity: number;
|
||||
}
|
||||
|
||||
const { font }: Props = $props();
|
||||
const { font, isVisible, proximity }: Props = $props();
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
selectedFontsStore.addOne(font);
|
||||
} else {
|
||||
selectedFontsStore.removeOne(font.id);
|
||||
let selected = $state(selectedFontsStore.has(font.id));
|
||||
let timeoutId = $state<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Create a spring for smooth scale animation
|
||||
const scale = new Spring(1, {
|
||||
stiffness: 0.3,
|
||||
damping: 0.7,
|
||||
});
|
||||
|
||||
// Springs react to the virtualizer's computed state
|
||||
const bloom = new Spring(0, {
|
||||
stiffness: 0.15,
|
||||
damping: 0.6,
|
||||
});
|
||||
|
||||
// Sync spring to proximity for a "Lens" effect
|
||||
$effect(() => {
|
||||
bloom.target = isVisible ? 1 : 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
selected = selectedFontsStore.has(font.id);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
const selected = $derived(selectedFontsStore.has(font.id));
|
||||
function handleClick() {
|
||||
animateSelection();
|
||||
selected ? selectedFontsStore.removeOne(font.id) : selectedFontsStore.addOne(font);
|
||||
}
|
||||
|
||||
function animateSelection() {
|
||||
scale.target = 0.98;
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
scale.target = 1;
|
||||
}, 150);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pb-1">
|
||||
<Label
|
||||
for={font.id}
|
||||
class="
|
||||
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3
|
||||
active:scale-[0.98] active:transition-transform active:duration-75
|
||||
has-aria-checked:border-blue-600
|
||||
has-aria-checked:bg-blue-50
|
||||
dark:has-aria-checked:border-blue-900
|
||||
dark:has-aria-checked:bg-blue-950
|
||||
<div
|
||||
class={cn('pb-1 will-change-transform')}
|
||||
style:opacity={bloom.current}
|
||||
style:transform="
|
||||
scale({0.92 + (bloom.current * 0.08)})
|
||||
translateY({(1 - bloom.current) * 10}px)
|
||||
"
|
||||
>
|
||||
<div style:transform={`scale(${scale.current})`}>
|
||||
<div
|
||||
class={cn(
|
||||
'w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3',
|
||||
'active:transition-transform active:duration-150',
|
||||
'border dark:border-slate-800',
|
||||
'bg-white/10 border-white/20',
|
||||
isVisible && 'bg-white/40 border-white/40',
|
||||
selected && 'ring-2 ring-indigo-600 ring-inset bg-indigo-50/50 hover:bg-indigo-50',
|
||||
)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onmousedown={(e => {
|
||||
// Prevent browser focus-jump
|
||||
if (e.currentTarget === document.activeElement) return;
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
})}
|
||||
onkeydown={(e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row gap-1 w-full items-center justify-between">
|
||||
@@ -63,22 +127,8 @@ const selected = $derived(selectedFontsStore.has(font.id));
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
id={font.id}
|
||||
checked={selected}
|
||||
onCheckedChange={handleChange}
|
||||
class="
|
||||
transition-all duration-150 ease-out
|
||||
data-[state=checked]:scale-100
|
||||
data-[state=checked]:border-blue-600
|
||||
data-[state=checked]:bg-blue-600
|
||||
data-[state=checked]:text-white
|
||||
dark:data-[state=checked]:border-blue-700
|
||||
dark:data-[state=checked]:bg-blue-700
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { appliedFontsManager } from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { ComparisonSlider } from '$widgets/ComparisonSlider/ui';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { displayedFontsStore } from '../../model';
|
||||
import PairSelector from '../PairSelector/PairSelector.svelte';
|
||||
|
||||
@@ -20,16 +21,17 @@ $effect(() => {
|
||||
{#if hasAnyPairs}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row gap-4">
|
||||
<Input bind:value={displayedText} />
|
||||
<PairSelector />
|
||||
</div>
|
||||
|
||||
{#if fontA && fontB}
|
||||
<div in:fly={{ y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }}>
|
||||
<ComparisonSlider
|
||||
fontA={fontA}
|
||||
fontB={fontB}
|
||||
bind:text={displayedText}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
</script>
|
||||
|
||||
<FontVirtualList items={fontshareStore.fonts}>
|
||||
{#snippet children({ item: font })}
|
||||
<FontListItem {font} />
|
||||
{#snippet children({ item: font, isVisible, proximity })}
|
||||
<FontListItem {font} {isVisible} {proximity} />
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
|
||||
@@ -21,3 +21,11 @@ export const DEFAULT_LINE_HEIGHT = 1.5;
|
||||
export const MIN_LINE_HEIGHT = 1;
|
||||
export const MAX_LINE_HEIGHT = 2;
|
||||
export const LINE_HEIGHT_STEP = 0.05;
|
||||
|
||||
/**
|
||||
* Letter spacing constants
|
||||
*/
|
||||
export const DEFAULT_LETTER_SPACING = 0;
|
||||
export const MIN_LETTER_SPACING = -0.1;
|
||||
export const MAX_LETTER_SPACING = 0.5;
|
||||
export const LETTER_SPACING_STEP = 0.01;
|
||||
|
||||
@@ -3,15 +3,19 @@ import { createTypographyControlManager } from '../../lib';
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
} from '../const/const';
|
||||
|
||||
@@ -49,6 +53,17 @@ const controlData: ControlModel[] = [
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Line Height',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Letter Spacing',
|
||||
},
|
||||
];
|
||||
|
||||
export const controlManager = createTypographyControlManager(controlData);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Check if we are in a browser environment
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
// A class to manage motion preference and provide a single instance for use everywhere
|
||||
class MotionPreference {
|
||||
// Reactive state
|
||||
#reduced = $state(false);
|
||||
|
||||
constructor() {
|
||||
if (isBrowser) {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
// Set initial value immediately
|
||||
this.#reduced = mediaQuery.matches;
|
||||
|
||||
// Simple listener that updates the reactive state
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
this.#reduced = e.matches;
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
}
|
||||
}
|
||||
|
||||
// Getter allows us to use 'motion.reduced' reactively in components
|
||||
get reduced() {
|
||||
return this.#reduced;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a single instance to be used everywhere
|
||||
export const motion = new MotionPreference();
|
||||
@@ -49,10 +49,6 @@ export function createCharacterComparison(
|
||||
* Matches the Tailwind breakpoints used in the component.
|
||||
*/
|
||||
function getFontSize() {
|
||||
// const fontSize = size();
|
||||
// if (fontSize) {
|
||||
// return fontSize;
|
||||
// }
|
||||
if (typeof window === 'undefined') {
|
||||
return 64;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface VirtualItem {
|
||||
end: number;
|
||||
/** Unique key for the item (for Svelte's {#each} keying) */
|
||||
key: string | number;
|
||||
/** Whether the item is currently visible in the viewport */
|
||||
isVisible: boolean;
|
||||
/** Proximity of the item to the center of the viewport */
|
||||
proximity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +140,8 @@ export function createVirtualizer<T>(
|
||||
|
||||
let endIdx = startIdx;
|
||||
const viewportEnd = scrollOffset + containerHeight;
|
||||
const viewportCenter = scrollOffset + (containerHeight / 2);
|
||||
|
||||
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||
endIdx++;
|
||||
}
|
||||
@@ -144,13 +150,31 @@ export function createVirtualizer<T>(
|
||||
const end = Math.min(count, endIdx + overscan);
|
||||
|
||||
const result: VirtualItem[] = [];
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const itemStart = offsets[i];
|
||||
const itemSize = measuredSizes[i] ?? options.estimateSize(i);
|
||||
const itemEnd = itemStart + itemSize;
|
||||
|
||||
// Visibility check: Does the item overlap the viewport?
|
||||
// const isVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
|
||||
// Fully visible
|
||||
const isVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||
|
||||
// Proximity calculation: 1.0 at center, 0.0 at edges
|
||||
const itemCenter = itemStart + (itemSize / 2);
|
||||
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
||||
const maxDistance = containerHeight / 2;
|
||||
const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance));
|
||||
|
||||
result.push({
|
||||
index: i,
|
||||
start: offsets[i],
|
||||
size: measuredSizes[i] ?? options.estimateSize(i),
|
||||
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
|
||||
start: itemStart,
|
||||
size: itemSize,
|
||||
end: itemEnd,
|
||||
key: options.getItemKey?.(i) ?? i,
|
||||
isVisible,
|
||||
proximity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,23 +231,25 @@ export function createVirtualizer<T>(
|
||||
const index = parseInt(node.dataset.index || '', 10);
|
||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||
|
||||
if (!isNaN(index) && measuredSizes[index] !== height) {
|
||||
// 1. Stuff the measurement into a temporary buffer
|
||||
if (!isNaN(index)) {
|
||||
const oldHeight = measuredSizes[index];
|
||||
// Only update if the height difference is significant (> 0.5px)
|
||||
// This prevents "jitter" from focus rings or sub-pixel border changes
|
||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||
// Stuff the measurement into a temporary buffer
|
||||
measurementBuffer[index] = height;
|
||||
|
||||
// 2. Schedule a single update for the next animation frame
|
||||
// Schedule a single update for the next animation frame
|
||||
if (frameId === null) {
|
||||
frameId = requestAnimationFrame(() => {
|
||||
// 3. Update the state once for all collected measurements
|
||||
// We use spread to trigger a single fine-grained update
|
||||
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
||||
|
||||
// 4. Reset the buffer
|
||||
// Reset the buffer
|
||||
measurementBuffer = {};
|
||||
frameId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(node);
|
||||
|
||||
@@ -19,5 +19,4 @@ export {
|
||||
type VirtualizerOptions,
|
||||
} from './helpers';
|
||||
|
||||
export { motion } from './accessibility/motion.svelte';
|
||||
export { splitArray } from './utils';
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Filter } from '$shared/lib';
|
||||
import { motion } from '$shared/lib';
|
||||
import { Badge } from '$shared/shadcn/ui/badge';
|
||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface PropertyFilterProps {
|
||||
@@ -37,7 +37,7 @@ let isOpen = $state(true);
|
||||
// Animation config respects user preferences - zero duration if reduced motion enabled
|
||||
// Local modifier prevents animation on initial render, only animates user interactions
|
||||
const slideConfig = $derived({
|
||||
duration: motion.reduced ? 0 : 250,
|
||||
duration: prefersReducedMotion.current ? 0 : 250,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
|
||||
@@ -70,7 +70,14 @@ function handleInputClick() {
|
||||
bind:value={value}
|
||||
onkeydown={handleKeyDown}
|
||||
onclick={handleInputClick}
|
||||
class="flex flex-row flex-1"
|
||||
class="
|
||||
h-20 w-full md:text-2xl backdrop-blur-sm bg-white/60 dark:bg-slate-900/40
|
||||
border-2 border-slate-200/50 dark:border-slate-700/50
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/50
|
||||
hover:bg-white/70 dark:hover:bg-slate-900/50 text-slate-900 dark:text-slate-100
|
||||
placeholder:text-slate-400 px-6 py-4 rounded-2xl transition-all duration-300
|
||||
font-medium
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -83,7 +90,7 @@ function handleInputClick() {
|
||||
e.preventDefault();
|
||||
}
|
||||
})}
|
||||
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)"
|
||||
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width) md:rounded-2xl"
|
||||
>
|
||||
{@render children?.({ id: contentId })}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -52,7 +52,7 @@ interface Props {
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
children: Snippet<[{ item: T; index: number }]>;
|
||||
children: Snippet<[{ item: T; index: number; isVisible: boolean; proximity: number }]>;
|
||||
}
|
||||
|
||||
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }:
|
||||
@@ -76,8 +76,13 @@ $effect(() => {
|
||||
class={cn(
|
||||
'relative overflow-auto rounded-md bg-background',
|
||||
'h-150 w-full',
|
||||
'scroll-smooth',
|
||||
className,
|
||||
)}
|
||||
onfocusin={(e => {
|
||||
// Prevent the browser from jumping the scroll when an inner element gets focus
|
||||
e.preventDefault();
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style:height="{virtualizer.totalSize}px"
|
||||
@@ -92,7 +97,12 @@ $effect(() => {
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style:transform="translateY({item.start}px)"
|
||||
>
|
||||
{@render children({ item: items[item.index], index: item.index })}
|
||||
{@render children({
|
||||
item: items[item.index],
|
||||
index: item.index,
|
||||
isVisible: item.isVisible,
|
||||
proximity: item.proximity,
|
||||
})}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -171,7 +171,7 @@ $effect(() => {
|
||||
'animate-nudge relative transition-all',
|
||||
side === 'left' ? 'order-2' : 'order-0',
|
||||
isActive ? 'opacity-0' : 'opacity-100',
|
||||
isDragging && 'opacity-40 grayscale-[0.2]',
|
||||
isDragging && 'opacity-80 grayscale-[0.2]',
|
||||
)}
|
||||
>
|
||||
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
|
||||
@@ -183,7 +183,7 @@ $effect(() => {
|
||||
isActive
|
||||
? 'bg-white/95 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
|
||||
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
||||
isDragging && 'opacity-40 grayscale-[0.2]',
|
||||
isDragging && 'opacity-80 grayscale-[0.2]',
|
||||
)}
|
||||
style:backdrop-filter="blur(24px)"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user