diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 490ac98c..46c96d5c 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -145,7 +145,6 @@ export function repl({ afterEval?.({ code, pattern, meta }); return pattern; } catch (err) { - // console.warn(`[repl] eval error: ${err.message}`); logger(`[eval] error: ${err.message}`, 'error'); updateState({ evalError: err, pending: false }); onEvalError?.(err); diff --git a/website/src/components/Oven/Oven.jsx b/website/src/components/Oven/Oven.jsx index 30822d6c..a4199b5e 100644 --- a/website/src/components/Oven/Oven.jsx +++ b/website/src/components/Oven/Oven.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { loadFeaturedPatterns, loadPublicPatterns } from '@src/repl/util.mjs'; +import { loadFeaturedPatterns, loadPublicPatterns } from '@src/user_pattern_utils.mjs'; import { MiniRepl } from '@src/docs/MiniRepl'; import { PatternLabel } from '@src/repl/panel/PatternsTab'; diff --git a/website/src/repl/Header.jsx b/website/src/repl/Header.jsx index 774d0142..748b84e4 100644 --- a/website/src/repl/Header.jsx +++ b/website/src/repl/Header.jsx @@ -19,7 +19,7 @@ export function Header({ context }) { isDirty, activeCode, handleTogglePlay, - handleUpdate, + handleEvaluate, handleShuffle, handleShare, } = context; @@ -85,7 +85,7 @@ export function Header({ context }) { )} - )} - - {!isExample && ( - - )} - - - )} -
- {Object.entries(userPatterns).map(([key, up]) => ( - { - const { code } = up; - setActivePattern(key); - context.handleUpdate(code, true); - }} - > - {key} - - ))} -
-
- - -
); } -export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) { - const meta = useMemo(() => getMetadata(pattern.code), [pattern]); +function ActionButton({ children, onClick, label, labelIsHidden }) { return ( - <> - {pattern.id}. {meta.title || pattern.hash} by {Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous'} - + + ); +} + +export function PatternsTab({ context }) { + const activePattern = useActivePattern(); + const viewingPatternStore = useViewingPatternData(); + const viewingPatternData = parseJSON(viewingPatternStore); + + 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 autoResetPatternOnChange = !window.parent?.location.pathname.includes('oodles'); + + return ( +
+ settingsMap.setKey('patternFilter', value)} + items={patternFilterName} + > + {patternFilter === patternFilterName.user && ( +
+
+ { + const { data } = userPattern.createAndAddToDB(); + updateCodeWindow(data); + }} + /> + { + const { data } = userPattern.duplicate(viewingPatternData); + updateCodeWindow(data); + }} + /> + { + const { data } = userPattern.delete(viewingPatternID); + updateCodeWindow({ ...data, collection: userPattern.collection }); + }} + /> + + + + { + const { data } = userPattern.clearAll(); + updateCodeWindow(data); + }} + /> +
+
+ )} + +
+ {patternFilter === patternFilterName.user && ( + + updateCodeWindow({ ...userPatterns[id], collection: userPattern.collection }, autoResetPatternOnChange) + } + patterns={userPatterns} + started={context.started} + activePattern={activePattern} + viewingPatternID={viewingPatternID} + /> + )} + {patternFilter !== patternFilterName.user && + Array.from(collections.keys()).map((collection) => { + const patterns = collections.get(collection); + return ( +
+

{collection}

+
+ updateCodeWindow({ ...patterns[id], collection }, autoResetPatternOnChange)} + started={context.started} + patterns={patterns} + activePattern={activePattern} + /> +
+
+ ); + })} +
+
); } diff --git a/website/src/repl/useExamplePatterns.jsx b/website/src/repl/useExamplePatterns.jsx new file mode 100644 index 00000000..08629d44 --- /dev/null +++ b/website/src/repl/useExamplePatterns.jsx @@ -0,0 +1,26 @@ +import { $featuredPatterns, $publicPatterns, collectionName } from '../user_pattern_utils.mjs'; +import { useStore } from '@nanostores/react'; +import { useMemo } from 'react'; +import * as tunes from '../repl/tunes.mjs'; + +export const stockPatterns = Object.fromEntries( + Object.entries(tunes).map(([key, code], i) => [i, { id: i, code, collection: 'Stock Examples' }]), +); + +export const useExamplePatterns = () => { + const featuredPatterns = useStore($featuredPatterns); + const publicPatterns = useStore($publicPatterns); + const collections = useMemo(() => { + const pats = new Map(); + pats.set(collectionName.featured, featuredPatterns); + pats.set(collectionName.public, publicPatterns); + // pats.set(collectionName.stock, stockPatterns); + return pats; + }, [featuredPatterns, publicPatterns]); + + const patterns = useMemo(() => { + return Object.assign({}, ...collections.values()); + }, [collections]); + + return { patterns, collections }; +}; diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs index 3a6758f6..65fdb4cf 100644 --- a/website/src/repl/util.mjs +++ b/website/src/repl/util.mjs @@ -4,12 +4,13 @@ import { getAudioContext, initializeAudioOutput, setDefaultAudioContext } from ' import { isTauri } from '../tauri.mjs'; import './Repl.css'; -import * as tunes from './tunes.mjs'; + import { createClient } from '@supabase/supabase-js'; import { nanoid } from 'nanoid'; import { writeText } from '@tauri-apps/api/clipboard'; import { createContext } from 'react'; -import { $publicPatterns, $featuredPatterns } from '../settings.mjs'; +import { stockPatterns } from './useExamplePatterns'; +import { loadDBPatterns } from '@src/user_pattern_utils.mjs'; // Create a single supabase client for interacting with your database export const supabase = createClient( @@ -17,25 +18,6 @@ export const supabase = createClient( 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM', ); -export function loadPublicPatterns() { - return supabase.from('code').select().eq('public', true).limit(20).order('id', { ascending: false }); -} - -export function loadFeaturedPatterns() { - return supabase.from('code').select().eq('featured', true).limit(20).order('id', { ascending: false }); -} - -async function loadDBPatterns() { - try { - const { data: publicPatterns } = await loadPublicPatterns(); - const { data: featuredPatterns } = await loadFeaturedPatterns(); - $publicPatterns.set(publicPatterns); - $featuredPatterns.set(featuredPatterns); - } catch (err) { - console.error('error loading patterns'); - } -} - if (typeof window !== 'undefined') { loadDBPatterns(); } @@ -70,11 +52,20 @@ export async function initCode() { } } +export const parseJSON = (json) => { + json = json != null && json.length ? json : '{}'; + try { + return JSON.parse(json); + } catch { + return '{}'; + } +}; + export function getRandomTune() { - const allTunes = Object.entries(tunes); + const allTunes = Object.entries(stockPatterns); const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)]; - const [name, code] = randomItem(allTunes); - return { name, code }; + const [id, data] = randomItem(allTunes); + return data; } export function loadModules() { diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 2b00c812..27888800 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -1,12 +1,6 @@ -import { atom } from 'nanostores'; -import { persistentMap, persistentAtom } from '@nanostores/persistent'; +import { persistentMap } from '@nanostores/persistent'; import { useStore } from '@nanostores/react'; import { register } from '@strudel/core'; -import * as tunes from './repl/tunes.mjs'; -import { logger } from '@strudel/core'; - -export let $publicPatterns = atom([]); -export let $featuredPatterns = atom([]); export const defaultAudioDeviceName = 'System Standard'; @@ -26,6 +20,7 @@ export const defaultSettings = { latestCode: '', isZen: false, soundsFilter: 'all', + patternFilter: 'community', panelPosition: 'right', userPatterns: '{}', audioDeviceName: defaultAudioDeviceName, @@ -33,26 +28,15 @@ export const defaultSettings = { export const settingsMap = persistentMap('strudel-settings', defaultSettings); -// active pattern is separate, because it shouldn't sync state across tabs -// reason: https://github.com/tidalcycles/strudel/issues/857 -const $activePattern = persistentAtom('activePattern', '', { listen: false }); -export function setActivePattern(key) { - $activePattern.set(key); -} -export function getActivePattern() { - return $activePattern.get(); -} -export function useActivePattern() { - return useStore($activePattern); -} -export function initUserCode(code) { - const userPatterns = getUserPatterns(); - const match = Object.entries(userPatterns).find(([_, pat]) => pat.code === code); - setActivePattern(match?.[0] || ''); -} - export function useSettings() { const state = useStore(settingsMap); + + const userPatterns = JSON.parse(state.userPatterns); + Object.keys(userPatterns).forEach((key) => { + const data = userPatterns[key]; + data.id = data.id ?? key; + userPatterns[key] = data; + }); return { ...state, isZen: [true, 'true'].includes(state.isZen) ? true : false, @@ -65,13 +49,12 @@ export function useSettings() { isFlashEnabled: [true, 'true'].includes(state.isFlashEnabled) ? true : false, fontSize: Number(state.fontSize), panelPosition: state.activeFooter !== '' ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is! - userPatterns: JSON.parse(state.userPatterns), + userPatterns: userPatterns, }; } export const setActiveFooter = (tab) => settingsMap.setKey('activeFooter', tab); -export const setLatestCode = (code) => settingsMap.setKey('latestCode', code); export const setIsZen = (active) => settingsMap.setKey('isZen', !!active); const patternSetting = (key) => @@ -90,174 +73,3 @@ export const fontFamily = patternSetting('fontFamily'); export const fontSize = patternSetting('fontSize'); export const settingPatterns = { theme, fontFamily, fontSize }; - -export function getUserPatterns() { - return JSON.parse(settingsMap.get().userPatterns); -} -function getSetting(key) { - return settingsMap.get()[key]; -} - -export function setUserPatterns(obj) { - settingsMap.setKey('userPatterns', JSON.stringify(obj)); -} - -export function addUserPattern(name, config) { - if (typeof config !== 'object') { - throw new Error('addUserPattern expected object as second param'); - } - if (!config.code) { - throw new Error('addUserPattern expected code as property of second param'); - } - const userPatterns = getUserPatterns(); - setUserPatterns({ ...userPatterns, [name]: config }); -} - -export function newUserPattern() { - const userPatterns = getUserPatterns(); - const date = new Date().toISOString().split('T')[0]; - const todays = Object.entries(userPatterns).filter(([name]) => name.startsWith(date)); - const num = String(todays.length + 1).padStart(3, '0'); - const defaultNewPattern = 's("hh")'; - const name = date + '_' + num; - addUserPattern(name, { code: defaultNewPattern }); - setActivePattern(name); - return name; -} - -export function clearUserPatterns() { - if (!confirm(`This will delete all your patterns. Are you really sure?`)) { - return; - } - setUserPatterns({}); -} - -export function getNextCloneName(key) { - const userPatterns = getUserPatterns(); - const clones = Object.entries(userPatterns).filter(([name]) => name.startsWith(key)); - const num = String(clones.length + 1).padStart(3, '0'); - return key + '_' + num; -} - -export function getUserPattern(key) { - const userPatterns = getUserPatterns(); - return userPatterns[key]; -} - -export function renameActivePattern() { - let activePattern = getActivePattern(); - let userPatterns = getUserPatterns(); - if (!userPatterns[activePattern]) { - alert('Cannot rename examples'); - return; - } - const newName = prompt('Enter new name', activePattern); - if (newName === null) { - // canceled - return; - } - if (userPatterns[newName]) { - alert('Name already taken!'); - return; - } - userPatterns[newName] = userPatterns[activePattern]; // copy code - delete userPatterns[activePattern]; - setUserPatterns({ ...userPatterns }); - setActivePattern(newName); -} - -export function updateUserCode(code) { - const userPatterns = getUserPatterns(); - let activePattern = getActivePattern(); - // check if code is that of an example tune - const [example] = Object.entries(tunes).find(([_, tune]) => tune === code) || []; - if (example && (!activePattern || activePattern === example)) { - // select example - setActivePattern(example); - return; - } - const publicPattern = $publicPatterns.get().find((pat) => pat.code === code); - if (publicPattern) { - setActivePattern(publicPattern.hash); - return; - } - const featuredPattern = $featuredPatterns.get().find((pat) => pat.code === code); - if (featuredPattern) { - setActivePattern(featuredPattern.hash); - return; - } - if (!activePattern) { - // create new user pattern - activePattern = newUserPattern(); - setActivePattern(activePattern); - } else if ( - (!!tunes[activePattern] && code !== tunes[activePattern]) || // fork example tune? - $publicPatterns.get().find((p) => p.hash === activePattern) || // fork public pattern? - $featuredPatterns.get().find((p) => p.hash === activePattern) // fork featured pattern? - ) { - // fork example - activePattern = getNextCloneName(activePattern); - setActivePattern(activePattern); - } - setUserPatterns({ ...userPatterns, [activePattern]: { code } }); -} - -export function deleteActivePattern() { - let activePattern = getActivePattern(); - if (!activePattern) { - console.warn('cannot delete: no pattern selected'); - return; - } - const userPatterns = getUserPatterns(); - if (!userPatterns[activePattern]) { - alert('Cannot delete examples'); - return; - } - if (!confirm(`Really delete the selected pattern "${activePattern}"?`)) { - return; - } - setUserPatterns(Object.fromEntries(Object.entries(userPatterns).filter(([key]) => key !== activePattern))); - setActivePattern(''); -} - -export function duplicateActivePattern() { - let activePattern = getActivePattern(); - let latestCode = getSetting('latestCode'); - if (!activePattern) { - console.warn('cannot duplicate: no pattern selected'); - return; - } - const userPatterns = getUserPatterns(); - activePattern = getNextCloneName(activePattern); - setUserPatterns({ ...userPatterns, [activePattern]: { code: latestCode } }); - setActivePattern(activePattern); -} - -export async function importPatterns(fileList) { - const files = Array.from(fileList); - await Promise.all( - files.map(async (file, i) => { - const content = await file.text(); - if (file.type === 'application/json') { - const userPatterns = getUserPatterns() || {}; - setUserPatterns({ ...userPatterns, ...JSON.parse(content) }); - } else if (file.type === 'text/plain') { - const name = file.name.replace(/\.[^/.]+$/, ''); - addUserPattern(name, { code: content }); - } - }), - ); - logger(`import done!`); -} - -export async function exportPatterns() { - const userPatterns = getUserPatterns() || {}; - const blob = new Blob([JSON.stringify(userPatterns)], { type: 'application/json' }); - const downloadLink = document.createElement('a'); - downloadLink.href = window.URL.createObjectURL(blob); - const date = new Date().toISOString().split('T')[0]; - downloadLink.download = `strudel_patterns_${date}.json`; - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); -} diff --git a/website/src/user_pattern_utils.mjs b/website/src/user_pattern_utils.mjs new file mode 100644 index 00000000..f9fef8d2 --- /dev/null +++ b/website/src/user_pattern_utils.mjs @@ -0,0 +1,188 @@ +import { atom } from 'nanostores'; +import { persistentAtom } from '@nanostores/persistent'; +import { useStore } from '@nanostores/react'; +import { logger } from '@strudel/core'; +import { nanoid } from 'nanoid'; +import { settingsMap } from './settings.mjs'; +import { parseJSON, supabase } from './repl/util.mjs'; + +export let $publicPatterns = atom([]); +export let $featuredPatterns = atom([]); + +export const collectionName = { + user: 'user', + public: 'Last Creations', + stock: 'Stock Examples', + featured: 'Featured', +}; + +export const patternFilterName = { + community: 'community', + user: 'user', +}; + +export let $viewingPatternData = persistentAtom( + 'viewingPatternData', + { + id: '', + code: '', + collection: collectionName.user, + created_at: Date.now(), + }, + { listen: false }, +); + +export const getViewingPatternData = () => { + return parseJSON($viewingPatternData.get()); +}; +export const useViewingPatternData = () => { + return useStore($viewingPatternData); +}; + +export const setViewingPatternData = (data) => { + $viewingPatternData.set(JSON.stringify(data)); +}; + +export function loadPublicPatterns() { + return supabase.from('code').select().eq('public', true).limit(20).order('id', { ascending: false }); +} + +export function loadFeaturedPatterns() { + return supabase.from('code').select().eq('featured', true).limit(20).order('id', { ascending: false }); +} + +export async function loadDBPatterns() { + try { + const { data: publicPatterns } = await loadPublicPatterns(); + const { data: featuredPatterns } = await loadFeaturedPatterns(); + 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) { + console.error('error loading patterns', err); + } +} + +// reason: https://github.com/tidalcycles/strudel/issues/857 +const $activePattern = persistentAtom('activePattern', '', { listen: false }); + +export function setActivePattern(key) { + $activePattern.set(key); +} +export function getActivePattern() { + return $activePattern.get(); +} +export function useActivePattern() { + return useStore($activePattern); +} + +export const setLatestCode = (code) => settingsMap.setKey('latestCode', code); + +const defaultCode = ''; +export const userPattern = { + collection: collectionName.user, + getAll() { + const patterns = parseJSON(settingsMap.get().userPatterns); + return patterns ?? {}; + }, + getPatternData(id) { + const userPatterns = this.getAll(); + return userPatterns[id]; + }, + exists(id) { + return this.getPatternData(id) != null; + }, + isValidID(id) { + return id != null && id.length > 0; + }, + + create() { + const newID = createPatternID(); + const code = defaultCode; + const data = { code, created_at: Date.now(), id: newID, collection: this.collection }; + return { id: newID, data }; + }, + createAndAddToDB() { + const newPattern = this.create(); + return this.update(newPattern.id, newPattern.data); + }, + + update(id, data) { + const userPatterns = this.getAll(); + data = { ...data, id, collection: this.collection }; + setUserPatterns({ ...userPatterns, [id]: data }); + return { id, data }; + }, + duplicate(data) { + const newPattern = this.create(); + return this.update(newPattern.id, { ...newPattern.data, code: data.code }); + }, + clearAll() { + if (!confirm(`This will delete all your patterns. Are you really sure?`)) { + return; + } + const viewingPatternData = getViewingPatternData(); + setUserPatterns({}); + + if (viewingPatternData.collection !== this.collection) { + return { id: viewingPatternData.id, data: viewingPatternData }; + } + setActivePattern(null); + return this.create(); + }, + delete(id) { + const userPatterns = this.getAll(); + delete userPatterns[id]; + if (getActivePattern() === id) { + setActivePattern(null); + } + setUserPatterns(userPatterns); + const viewingPatternData = getViewingPatternData(); + const viewingID = viewingPatternData?.id; + if (viewingID === id) { + return { id: null, data: { code: defaultCode } }; + } + return { id: viewingID, data: userPatterns[viewingID] }; + }, +}; + +function setUserPatterns(obj) { + return settingsMap.setKey('userPatterns', JSON.stringify(obj)); +} + +export const createPatternID = () => { + return nanoid(12); +}; + +export async function importPatterns(fileList) { + const files = Array.from(fileList); + await Promise.all( + files.map(async (file, i) => { + const content = await file.text(); + if (file.type === 'application/json') { + const userPatterns = userPattern.getAll(); + setUserPatterns({ ...userPatterns, ...parseJSON(content) }); + } else if (file.type === 'text/plain') { + const id = file.name.replace(/\.[^/.]+$/, ''); + userPattern.update(id, { code: content }); + } + }), + ); + logger(`import done!`); +} + +export async function exportPatterns() { + const userPatterns = userPattern.getAll(); + const blob = new Blob([JSON.stringify(userPatterns)], { type: 'application/json' }); + const downloadLink = document.createElement('a'); + downloadLink.href = window.URL.createObjectURL(blob); + const date = new Date().toISOString().split('T')[0]; + downloadLink.download = `strudel_patterns_${date}.json`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +}