Merge pull request #1287 from daslyfe/jade/supabasequery

Create Pattern Page Pagination
This commit is contained in:
Jade (Rose) Rowland 2025-02-21 20:18:57 -05:00 committed by GitHub
commit 1f233b9e7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 336 additions and 129 deletions

View File

@ -0,0 +1,67 @@
import { Textbox } from '../textbox/Textbox';
import cx from '@src/cx.mjs';
function IncButton({ children, className, ...buttonProps }) {
return (
<button
tabIndex={-1}
className={cx(
'border border-transparent p-1 text-center text-sm transition-all hover:bg-foreground active:bg-lineBackground disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none',
className,
)}
type="button"
{...buttonProps}
>
{children}
</button>
);
}
export function Incrementor({
onChange,
value,
min = -Infinity,
max = Infinity,
className,
incrementLabel = 'next page',
decrementLabel = 'prev page',
...incrementorProps
}) {
value = parseInt(value);
value = isNaN(value) ? '' : value;
return (
<div className={cx('w-fit bg-background relative flex items-center"> rounded-md', className)}>
<Textbox
min={min}
max={max}
onChange={(v) => {
if (v.length && v < min) {
return;
}
onChange(v);
}}
type="number"
placeholder=""
value={value}
className="w-32 mb-0 mt-0 border-none rounded-r-none bg-transparent appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
{...incrementorProps}
/>
<div className="flex gap-1 ">
<IncButton disabled={value <= min} onClick={() => onChange(value - 1)} aria-label={decrementLabel}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="w-4 h-4">
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</IncButton>
<IncButton
className="rounded-r-md"
disabled={value >= max}
onClick={() => onChange(value + 1)}
aria-label={incrementLabel}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="w-4 h-4">
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
</svg>
</IncButton>
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { Incrementor } from '../incrementor/Incrementor';
export function Pagination({ currPage, onPageChange, className, ...incrementorProps }) {
return <Incrementor min={1} value={currPage} onChange={onPageChange} className={className} {...incrementorProps} />;
}

View File

@ -1,6 +1,8 @@
import { import {
exportPatterns, exportPatterns,
importPatterns, importPatterns,
loadAndSetFeaturedPatterns,
loadAndSetPublicPatterns,
patternFilterName, patternFilterName,
useActivePattern, useActivePattern,
useViewingPatternData, useViewingPatternData,
@ -12,10 +14,10 @@ import { useExamplePatterns } from '../../useExamplePatterns.jsx';
import { parseJSON, isUdels } from '../../util.mjs'; import { parseJSON, isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx'; import { ButtonGroup } from './Forms.jsx';
import { settingsMap, useSettings } from '../../../settings.mjs'; import { settingsMap, useSettings } from '../../../settings.mjs';
import { Pagination } from '../pagination/Pagination.jsx';
function classNames(...classes) { import { useState } from 'react';
return classes.filter(Boolean).join(' '); import { useDebounce } from '../usedebounce.jsx';
} import cx from '@src/cx.mjs';
export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) { export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) {
const meta = useMemo(() => getMetadata(pattern.code), [pattern]); const meta = useMemo(() => getMetadata(pattern.code), [pattern]);
@ -33,13 +35,15 @@ export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) {
if (title == null) { if (title == null) {
title = 'unnamed'; title = 'unnamed';
} }
return <>{`${pattern.id}: ${title} by ${Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous'}`}</>;
const author = Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous';
return <>{`${pattern.id}: ${title} by ${author.slice(0, 100)}`.slice(0, 60)}</>;
} }
function PatternButton({ showOutline, onClick, pattern, showHiglight }) { function PatternButton({ showOutline, onClick, pattern, showHiglight }) {
return ( return (
<a <a
className={classNames( className={cx(
'mr-4 hover:opacity-50 cursor-pointer block', 'mr-4 hover:opacity-50 cursor-pointer block',
showOutline && 'outline outline-1', showOutline && 'outline outline-1',
showHiglight && 'bg-selection', showHiglight && 'bg-selection',
@ -57,7 +61,7 @@ function PatternButtons({ patterns, activePattern, onClick, started }) {
const viewingPatternID = viewingPatternData.id; const viewingPatternID = viewingPatternData.id;
return ( return (
<div className="font-mono text-sm"> <div className="font-mono text-sm">
{Object.values(patterns) {Object.values(patterns ?? {})
.reverse() .reverse()
.map((pattern) => { .map((pattern) => {
const id = pattern.id; const id = pattern.id;
@ -84,82 +88,72 @@ function ActionButton({ children, onClick, label, labelIsHidden }) {
); );
} }
export function PatternsTab({ context }) { const updateCodeWindow = (context, patternData, reset = false) => {
context.handleUpdate(patternData, reset);
};
const autoResetPatternOnChange = !isUdels();
function UserPatterns({ context }) {
const activePattern = useActivePattern(); const activePattern = useActivePattern();
const viewingPatternStore = useViewingPatternData(); const viewingPatternStore = useViewingPatternData();
const viewingPatternData = parseJSON(viewingPatternStore); const viewingPatternData = parseJSON(viewingPatternStore);
const { userPatterns, patternFilter } = useSettings(); const { userPatterns, patternFilter } = useSettings();
const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections;
const updateCodeWindow = (patternData, reset = false) => {
context.handleUpdate(patternData, reset);
};
const viewingPatternID = viewingPatternData?.id; const viewingPatternID = viewingPatternData?.id;
const autoResetPatternOnChange = !isUdels();
return ( return (
<div className="px-4 w-full dark:text-white text-stone-900 space-y-2 flex flex-col overflow-hidden max-h-full h-full"> <div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 ">
<ButtonGroup <div className="pr-4 space-x-4 flex max-w-full overflow-x-auto">
value={patternFilter} <ActionButton
onChange={(value) => settingsMap.setKey('patternFilter', value)} label="new"
items={patternFilterName} onClick={() => {
></ButtonGroup> const { data } = userPattern.createAndAddToDB();
{patternFilter === patternFilterName.user && ( updateCodeWindow(context, data);
<div> }}
<div className="pr-4 space-x-4 border-b border-foreground flex max-w-full overflow-x-auto"> />
<ActionButton <ActionButton
label="new" label="duplicate"
onClick={() => { onClick={() => {
const { data } = userPattern.createAndAddToDB(); const { data } = userPattern.duplicate(viewingPatternData);
updateCodeWindow(data); updateCodeWindow(context, data);
}} }}
/> />
<ActionButton <ActionButton
label="duplicate" label="delete"
onClick={() => { onClick={() => {
const { data } = userPattern.duplicate(viewingPatternData); const { data } = userPattern.delete(viewingPatternID);
updateCodeWindow(data); updateCodeWindow(context, { ...data, collection: userPattern.collection });
}} }}
/> />
<ActionButton <label className="hover:opacity-50 cursor-pointer">
label="delete" <input
onClick={() => { style={{ display: 'none' }}
const { data } = userPattern.delete(viewingPatternID); type="file"
updateCodeWindow({ ...data, collection: userPattern.collection }); multiple
}} accept="text/plain,application/json"
/> onChange={(e) => importPatterns(e.target.files)}
<label className="hover:opacity-50 cursor-pointer"> />
<input import
style={{ display: 'none' }} </label>
type="file" <ActionButton label="export" onClick={exportPatterns} />
multiple
accept="text/plain,application/json"
onChange={(e) => importPatterns(e.target.files)}
/>
import
</label>
<ActionButton label="export" onClick={exportPatterns} />
<ActionButton <ActionButton
label="delete-all" label="delete-all"
onClick={() => { onClick={() => {
const { data } = userPattern.clearAll(); const { data } = userPattern.clearAll();
updateCodeWindow(data); updateCodeWindow(context, data);
}} }}
/> />
</div> </div>
</div>
)}
<section className="flex overflow-y-auto max-h-full flex-grow flex-col"> <div className="overflow-auto h-full bg-background p-2 rounded-md">
{patternFilter === patternFilterName.user && ( {patternFilter === patternFilterName.user && (
<PatternButtons <PatternButtons
onClick={(id) => onClick={(id) =>
updateCodeWindow({ ...userPatterns[id], collection: userPattern.collection }, autoResetPatternOnChange) updateCodeWindow(
context,
{ ...userPatterns[id], collection: userPattern.collection },
autoResetPatternOnChange,
)
} }
patterns={userPatterns} patterns={userPatterns}
started={context.started} started={context.started}
@ -167,24 +161,111 @@ export function PatternsTab({ context }) {
viewingPatternID={viewingPatternID} viewingPatternID={viewingPatternID}
/> />
)} )}
{patternFilter !== patternFilterName.user && </div>
Array.from(collections.keys()).map((collection) => { </div>
const patterns = collections.get(collection); );
return ( }
<section key={collection} className="py-2">
<h2 className="text-xl mb-2">{collection}</h2> function PatternPageWithPagination({ patterns, patternOnClick, context, paginationOnChange, initialPage }) {
<div className="font-mono text-sm"> const [page, setPage] = useState(initialPage);
<PatternButtons const debouncedPageChange = useDebounce(() => {
onClick={(id) => updateCodeWindow({ ...patterns[id], collection }, autoResetPatternOnChange)} paginationOnChange(page);
started={context.started} });
patterns={patterns}
activePattern={activePattern} const onPageChange = (pageNum) => {
/> setPage(pageNum);
</div> debouncedPageChange();
</section> };
);
})} const activePattern = useActivePattern();
</section> return (
<div className="flex flex-grow flex-col h-full overflow-hidden justify-between">
<div className="overflow-auto flex flex-col flex-grow bg-background p-2 rounded-md ">
<PatternButtons
onClick={(id) => patternOnClick(id)}
started={context.started}
patterns={patterns}
activePattern={activePattern}
/>
</div>
<div className="flex items-center gap-2 py-2">
<label htmlFor="pattern pagination">Page</label>
<Pagination id="pattern pagination" currPage={page} onPageChange={onPageChange} />
</div>
</div>
);
}
let featuredPageNum = 1;
function FeaturedPatterns({ context }) {
const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.featured);
return (
<PatternPageWithPagination
patterns={patterns}
context={context}
initialPage={featuredPageNum}
patternOnClick={(id) => {
updateCodeWindow(
context,
{ ...patterns[id], collection: patternFilterName.featured },
autoResetPatternOnChange,
);
}}
paginationOnChange={async (pageNum) => {
await loadAndSetFeaturedPatterns(pageNum - 1);
featuredPageNum = pageNum;
}}
/>
);
}
let latestPageNum = 1;
function LatestPatterns({ context }) {
const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.public);
return (
<PatternPageWithPagination
patterns={patterns}
context={context}
initialPage={latestPageNum}
patternOnClick={(id) => {
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, autoResetPatternOnChange);
}}
paginationOnChange={async (pageNum) => {
await loadAndSetPublicPatterns(pageNum - 1);
latestPageNum = pageNum;
}}
/>
);
}
function PublicPatterns({ context }) {
const { patternFilter } = useSettings();
if (patternFilter === patternFilterName.featured) {
return <FeaturedPatterns context={context} />;
}
return <LatestPatterns context={context} />;
}
export function PatternsTab({ context }) {
const { patternFilter } = useSettings();
return (
<div className="px-4 w-full text-foreground space-y-2 flex flex-col overflow-hidden max-h-full h-full">
<ButtonGroup
value={patternFilter}
onChange={(value) => settingsMap.setKey('patternFilter', value)}
items={patternFilterName}
></ButtonGroup>
{patternFilter === patternFilterName.user ? (
<UserPatterns context={context} />
) : (
<PublicPatterns context={context} />
)}
</div> </div>
); );
} }

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import jsdocJson from '../../../../../doc.json'; import jsdocJson from '../../../../../doc.json';
import { Textbox } from '../textbox/Textbox';
const availableFunctions = jsdocJson.docs const availableFunctions = jsdocJson.docs
.filter(({ name, description }) => name && !name.startsWith('_') && !!description) .filter(({ name, description }) => name && !name.startsWith('_') && !!description)
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name)); .sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
@ -28,12 +29,7 @@ export function Reference() {
<div className="flex h-full w-full p-2 text-foreground overflow-hidden"> <div className="flex h-full w-full p-2 text-foreground overflow-hidden">
<div className="h-full flex flex-col gap-2 w-1/3 max-w-72 "> <div className="h-full flex flex-col gap-2 w-1/3 max-w-72 ">
<div class="w-full flex"> <div class="w-full flex">
<input <Textbox className="w-full" placeholder="Search" value={search} onChange={setSearch} />
className="w-full p-1 bg-background rounded-md border-none"
placeholder="Search"
value={search}
onInput={(event) => setSearch(event.target.value)}
/>
</div> </div>
<div className="flex flex-col h-full overflow-y-auto gap-1.5 bg-background bg-opacity-50 rounded-md"> <div className="flex flex-col h-full overflow-y-auto gap-1.5 bg-background bg-opacity-50 rounded-md">
{visibleFunctions.map((entry, i) => ( {visibleFunctions.map((entry, i) => (

View File

@ -5,6 +5,7 @@ import { useMemo, useRef, useState } from 'react';
import { settingsMap, useSettings } from '../../../settings.mjs'; import { settingsMap, useSettings } from '../../../settings.mjs';
import { ButtonGroup } from './Forms.jsx'; import { ButtonGroup } from './Forms.jsx';
import ImportSoundsButton from './ImportSoundsButton.jsx'; import ImportSoundsButton from './ImportSoundsButton.jsx';
import { Textbox } from '../textbox/Textbox.jsx';
const getSamples = (samples) => const getSamples = (samples) =>
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1; Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
@ -53,12 +54,7 @@ export function SoundsTab() {
return ( return (
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900"> <div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900">
<input <Textbox placeholder="Search" value={search} onChange={(v) => setSearch(v)} />
className="w-full p-1 bg-background rounded-md my-2"
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="pb-2 flex shrink-0 flex-wrap"> <div className="pb-2 flex shrink-0 flex-wrap">
<ButtonGroup <ButtonGroup

View File

@ -0,0 +1,11 @@
import cx from '@src/cx.mjs';
export function Textbox({ onChange, className, ...inputProps }) {
return (
<input
className={cx('p-1 bg-background rounded-md my-2 border-foreground', className)}
onChange={(e) => onChange(e.target.value)}
{...inputProps}
/>
);
}

View File

@ -0,0 +1,30 @@
import { useMemo } from 'react';
import { useEffect } from 'react';
import { useRef } from 'react';
function debounce(fn, wait) {
let timer;
return function (...args) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => fn(...args), wait);
};
}
export function useDebounce(callback) {
const ref = useRef;
useEffect(() => {
ref.current = callback;
}, [callback]);
const debouncedCallback = useMemo(() => {
const func = () => {
ref.current?.();
};
return debounce(func, 1000);
}, []);
return debouncedCallback;
}

View File

@ -1,4 +1,4 @@
import { $featuredPatterns, $publicPatterns, collectionName } from '../user_pattern_utils.mjs'; import { $featuredPatterns, $publicPatterns, patternFilterName } from '../user_pattern_utils.mjs';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import * as tunes from '../repl/tunes.mjs'; import * as tunes from '../repl/tunes.mjs';
@ -12,9 +12,9 @@ export const useExamplePatterns = () => {
const publicPatterns = useStore($publicPatterns); const publicPatterns = useStore($publicPatterns);
const collections = useMemo(() => { const collections = useMemo(() => {
const pats = new Map(); const pats = new Map();
pats.set(collectionName.featured, featuredPatterns); pats.set(patternFilterName.featured, featuredPatterns);
pats.set(collectionName.public, publicPatterns); pats.set(patternFilterName.public, publicPatterns);
// pats.set(collectionName.stock, stockPatterns); // pats.set(patternFilterName.stock, stockPatterns);
return pats; return pats;
}, [featuredPatterns, publicPatterns]); }, [featuredPatterns, publicPatterns]);

View File

@ -8,16 +8,12 @@ import { confirmDialog, parseJSON, supabase } from './repl/util.mjs';
export let $publicPatterns = atom([]); export let $publicPatterns = atom([]);
export let $featuredPatterns = atom([]); export let $featuredPatterns = atom([]);
export const collectionName = { const patternQueryLimit = 20;
user: 'user',
public: 'Last Creations',
stock: 'Stock Examples',
featured: 'Featured',
};
export const patternFilterName = { export const patternFilterName = {
community: 'community', public: 'latest',
featured: 'featured',
user: 'user', user: 'user',
// stock: 'stock examples',
}; };
const sessionAtom = (name, initial = undefined) => { const sessionAtom = (name, initial = undefined) => {
@ -36,7 +32,7 @@ const sessionAtom = (name, initial = undefined) => {
export let $viewingPatternData = sessionAtom('viewingPatternData', { export let $viewingPatternData = sessionAtom('viewingPatternData', {
id: '', id: '',
code: '', code: '',
collection: collectionName.user, collection: patternFilterName.user,
created_at: Date.now(), created_at: Date.now(),
}); });
@ -51,25 +47,50 @@ export const setViewingPatternData = (data) => {
$viewingPatternData.set(JSON.stringify(data)); $viewingPatternData.set(JSON.stringify(data));
}; };
export function loadPublicPatterns() { function parsePageNum(page) {
return supabase.from('code_v1').select().eq('public', true).limit(20).order('id', { ascending: false }); return isNaN(page) ? 0 : page;
}
export function loadPublicPatterns(page) {
page = parsePageNum(page);
const offset = page * patternQueryLimit;
return supabase
.from('code_v1')
.select()
.eq('public', true)
.range(offset, offset + patternQueryLimit)
.order('id', { ascending: false });
} }
export function loadFeaturedPatterns() { export function loadFeaturedPatterns(page = 0) {
return supabase.from('code_v1').select().eq('featured', true).limit(20).order('id', { ascending: false }); page = parsePageNum(page);
const offset = page * patternQueryLimit;
return supabase
.from('code_v1')
.select()
.eq('featured', true)
.range(offset, offset + patternQueryLimit)
.order('id', { ascending: false });
}
export async function loadAndSetPublicPatterns(page) {
const p = await loadPublicPatterns(page);
const data = p?.data;
const pats = {};
data?.forEach((data, key) => (pats[data.id ?? key] = data));
$publicPatterns.set(pats);
}
export async function loadAndSetFeaturedPatterns(page) {
const p = await loadFeaturedPatterns(page);
const data = p?.data;
const pats = {};
data?.forEach((data, key) => (pats[data.id ?? key] = data));
$featuredPatterns.set(pats);
} }
export async function loadDBPatterns() { export async function loadDBPatterns() {
try { try {
const { data: publicPatterns } = await loadPublicPatterns(); await loadAndSetPublicPatterns();
const { data: featuredPatterns } = await loadFeaturedPatterns(); await loadAndSetFeaturedPatterns();
const featured = {};
const pub = {};
publicPatterns?.forEach((data, key) => (pub[data.id ?? key] = data));
featuredPatterns?.forEach((data, key) => (featured[data.id ?? key] = data));
$publicPatterns.set(pub);
$featuredPatterns.set(featured);
} catch (err) { } catch (err) {
console.error('error loading patterns', err); console.error('error loading patterns', err);
} }
@ -92,7 +113,7 @@ export const setLatestCode = (code) => settingsMap.setKey('latestCode', code);
const defaultCode = ''; const defaultCode = '';
export const userPattern = { export const userPattern = {
collection: collectionName.user, collection: patternFilterName.user,
getAll() { getAll() {
const patterns = parseJSON(settingsMap.get().userPatterns); const patterns = parseJSON(settingsMap.get().userPatterns);
return patterns ?? {}; return patterns ?? {};