From 6cb156d876aff63f965f611dda51529739fa185c Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Thu, 18 Jan 2024 20:01:54 -0500 Subject: [PATCH] cleaning up --- website/src/repl/Repl.jsx | 28 ++- website/src/repl/panel/PatternsTab.jsx | 129 ++++--------- website/src/repl/useExamplePatterns.jsx | 23 +-- website/src/repl/util.mjs | 28 --- website/src/settings.mjs | 241 +----------------------- website/src/user_pattern_utils.mjs | 190 +++++++++++++++++++ 6 files changed, 249 insertions(+), 390 deletions(-) create mode 100644 website/src/user_pattern_utils.mjs diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 9bd26e02..8ec9feb2 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -12,18 +12,17 @@ import { defaultAudioDeviceName } from '../settings.mjs'; import { getAudioDevices, setAudioDevice } from './util.mjs'; import { StrudelMirror, defaultSettings } from '@strudel/codemirror'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { settingsMap, useSettings } from '../settings.mjs'; import { initUserCode, setActivePattern, setLatestCode, - settingsMap, - useSettings, - getViewingPattern, setViewingPattern, createPatternID, userPattern, - getNextCloneID, -} from '../settings.mjs'; + getViewingPatternData, + setViewingPatternData, +} from '../user_pattern_utils.mjs'; import { Header } from './Header'; import Loader from './Loader'; import { Panel } from './panel/Panel'; @@ -32,12 +31,8 @@ import { prebake } from './prebake.mjs'; import { getRandomTune, initCode, loadModules, shareCode, ReplContext } from './util.mjs'; import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; import './Repl.css'; -import { useExamplePatterns } from './useExamplePatterns'; const { code: randomTune, name } = getRandomTune(); - -// - const { latestCode } = settingsMap.get(); let modulesLoading, presets, drawContext, clearCanvas, isIframe; @@ -50,8 +45,6 @@ if (typeof window !== 'undefined') { isIframe = window.location !== window.parent.location; } -let viewingPatternData = { id: '', code: null, collection: userPattern.source }; - export function Repl({ embedded = false }) { const isEmbedded = embedded || isIframe; const { panelPosition, isZen } = useSettings(); @@ -83,23 +76,24 @@ export function Repl({ embedded = false }) { const { code } = all; setLatestCode(code); window.location.hash = '#' + code2hash(code); + const viewingPatternData = getViewingPatternData(); const data = { ...viewingPatternData, code }; - let id = getViewingPattern(); - const isExamplePattern = viewingPatternData.collection != userPattern.source; + let id = data.id; + const isExamplePattern = viewingPatternData.collection !== userPattern.collection; if (isExamplePattern) { const codeHasChanged = code !== viewingPatternData.code; if (codeHasChanged) { // fork example - id = getNextCloneID(id); + id = createPatternID(); setViewingPattern(id); - viewingPatternData = userPattern.update(id, data).data; + setViewingPatternData(userPattern.update(id, data).data); } } else { id = id == null ? createPatternID() : id; setViewingPattern(id); - viewingPatternData = userPattern.update(id, data).data; + setViewingPatternData(userPattern.update(id, data).data); } setActivePattern(id); }, @@ -176,7 +170,7 @@ export function Repl({ embedded = false }) { }; const handleUpdate = async (id, data, reset = false) => { - viewingPatternData = data; + setViewingPatternData(data); if (reset) { await resetEditor(); } diff --git a/website/src/repl/panel/PatternsTab.jsx b/website/src/repl/panel/PatternsTab.jsx index 60ca4465..a950d754 100644 --- a/website/src/repl/panel/PatternsTab.jsx +++ b/website/src/repl/panel/PatternsTab.jsx @@ -1,20 +1,13 @@ -import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/20/solid'; - +import { DocumentDuplicateIcon, TrashIcon } from '@heroicons/react/20/solid'; +import { useSettings } from '../../settings.mjs'; import { - $featuredPatterns, - $publicPatterns, exportPatterns, importPatterns, useActivePattern, useViewingPattern, - useSettings, userPattern, - examplePattern, -} from '../../settings.mjs'; - +} from '../../user_pattern_utils.mjs'; import { useMemo } from 'react'; - -import { useStore } from '@nanostores/react'; import { getMetadata } from '../../metadata_parser'; import { useExamplePatterns } from '../useExamplePatterns'; @@ -24,19 +17,14 @@ function classNames(...classes) { function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) { const meta = useMemo(() => getMetadata(pattern.code), [pattern]); + return ( - <>{`${pattern.id}: ${meta.title ?? pattern.hash ?? 'unnamed'} by ${ - Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous' - }`} + <>{`${pattern.id}: ${ + meta.title ?? pattern.hash ?? new Date(pattern.created_at).toLocaleDateString() ?? 'unnamed' + } by ${Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous'}`} ); } -const getPatternLabel = (pattern) => { - return `${pattern.id}: ${meta.title ?? pattern.hash ?? 'unnamed'} by ${ - Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous' - }`; -}; - function PatternButton({ showOutline, onClick, pattern, showHiglight }) { return ( { -// setActivePattern(pattern.hash); -// context.handleUpdate(pattern.code, true); -// }} -// > -// -// -// ); -// } - function PatternButtons({ patterns, activePattern, onClick, viewingPattern, started }) { return (
@@ -89,28 +59,27 @@ function PatternButtons({ patterns, activePattern, onClick, viewingPattern, star ); } +function ActionButton({ children, onClick, label, labelIsHidden }) { + return ( + + ); +} + export function PatternsTab({ context }) { const activePattern = useActivePattern(); const viewingPattern = useViewingPattern(); const { userPatterns } = useSettings(); const examplePatterns = useExamplePatterns(); const collections = examplePatterns.collections; - const examplesData = examplePatterns.patterns; const updateCodeWindow = (id, data, reset = false) => { context.handleUpdate(id, data, reset); - // if (patternSource === userPattern.source) { - - // } else { - // const source = otherPatterns.get(patternSource); - // const data = source[id]; - // } }; - const isUserPattern = userPatterns[viewingPattern] != null; - // const isExample = useMemo(() => activePattern && !!tunes[activePattern], [activePattern]); - return (
@@ -118,69 +87,56 @@ export function PatternsTab({ context }) {

{`${viewingPattern}`}

- {/* {!isExample && ( - - )} */} - + {isUserPattern && ( - + )}
)} updateCodeWindow(id, { ...userPatterns[id], collection: userPattern.source }, false)} + onClick={(id) => updateCodeWindow(id, { ...userPatterns[id], collection: userPattern.collection }, false)} patterns={userPatterns} started={context.started} activePattern={activePattern} viewingPattern={viewingPattern} />
- - + /> + - +
{Array.from(collections.keys()).map((collection) => { const patterns = collections.get(collection); - return (

{collection}

diff --git a/website/src/repl/useExamplePatterns.jsx b/website/src/repl/useExamplePatterns.jsx index f51d9091..65d94583 100644 --- a/website/src/repl/useExamplePatterns.jsx +++ b/website/src/repl/useExamplePatterns.jsx @@ -1,34 +1,23 @@ -import { examplePattern, $featuredPatterns, $publicPatterns } from '../settings.mjs'; +import { $featuredPatterns, $publicPatterns } from '../user_pattern_utils.mjs'; import { useStore } from '@nanostores/react'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; +import * as tunes from '../repl/tunes.mjs'; export const useExamplePatterns = () => { const featuredPatterns = useStore($featuredPatterns); const publicPatterns = useStore($publicPatterns); const collections = useMemo(() => { + const stockPatterns = Object.fromEntries(Object.entries(tunes).map(([key, code], i) => [i, { id: i, code }])); const pats = new Map(); pats.set('Featured', featuredPatterns); pats.set('Last Creations', publicPatterns); - pats.set(examplePattern.source, examplePattern.getAll()); + pats.set('Stock Examples', stockPatterns); return pats; }, [featuredPatterns, publicPatterns]); const patterns = useMemo(() => { - const allPatterns = Object.assign({}, ...collections.values()); - return allPatterns; + return Object.assign({}, ...collections.values()); }, [collections]); - // const examplePatterns = examplePattern.getAll(); - - // const collections = new Map(); - // collections.set('Featured', featuredPatterns); - // collections.set('Last Creations', publicPatterns); - // collections.set(examplePattern.source, examplePatterns); - // const patterns = { - // ...examplePatterns, - // ...publicPatterns, - // ...examplePatterns, - // }; - return { patterns, collections }; }; diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs index 1da4fc65..19afdabb 100644 --- a/website/src/repl/util.mjs +++ b/website/src/repl/util.mjs @@ -9,7 +9,6 @@ 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'; // Create a single supabase client for interacting with your database export const supabase = createClient( @@ -17,33 +16,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(); - 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'); - } -} - -if (typeof window !== 'undefined') { - loadDBPatterns(); -} - export async function initCode() { // load code from url hash (either short hash from database or decode long hash) try { diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 9f2da640..8815a215 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.cycles/core'; -import * as tunes from './repl/tunes.mjs'; -import { logger } from '@strudel.cycles/core'; -import { nanoid } from 'nanoid'; -export let $publicPatterns = atom([]); -export let $featuredPatterns = atom([]); export const defaultAudioDeviceName = 'System Standard'; @@ -33,54 +27,6 @@ export const defaultSettings = { export const settingsMap = persistentMap('strudel-settings', defaultSettings); -const defaultCode = ''; -//pattern that the use is currently viewing in the window -const $viewingPattern = persistentAtom('viewingPattern', '', { listen: false }); -export function setViewingPattern(key) { - $viewingPattern.set(key); -} -export function getViewingPattern() { - return $viewingPattern.get(); -} - -export function useViewingPattern() { - return useStore($viewingPattern); -} - -// const $viewingCollection = persistentAtom('viewingCollection', '', { listen: false }); -// export function setViewingCollection(key) { -// $viewingCollection.set(key); -// } -// export function getViewingCollection() { -// return $viewingCollection.get(); -// } - -// export function useViewingCollection() { -// return useStore($viewingCollection); -// } -// 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 patterns = { ...userPattern.getAll(), ...examplePattern.getAll() }; - const match = Object.entries(patterns).find(([_, pat]) => pat.code === code); - const id = match?.[0]; - if (id != null) { - setActivePattern(id); - setViewingPattern(id); - } -} - export function useSettings() { const state = useStore(settingsMap); @@ -88,7 +34,6 @@ export function useSettings() { Object.keys(userPatterns).forEach((key) => { const data = userPatterns[key]; data.id = data.id ?? key; - data.date = data.date ?? 0; userPatterns[key] = data; }); return { @@ -109,7 +54,6 @@ export function useSettings() { 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) => @@ -128,186 +72,3 @@ export const fontFamily = patternSetting('fontFamily'); export const fontSize = patternSetting('fontSize'); export const settingPatterns = { theme, fontFamily, fontSize }; - -function getUserPatterns() { - return JSON.parse(settingsMap.get().userPatterns); -} - -function setUserPatterns(obj) { - return settingsMap.setKey('userPatterns', JSON.stringify(obj)); -} - -export const createPatternID = () => { - return nanoid(12); -}; - -export const getNextCloneID = (id) => { - return createPatternID(); -}; - -const examplePatterns = Object.fromEntries(Object.entries(tunes).map(([key, code], i) => [i, { id: i, code }])); - -export const examplePattern = { - source: 'Stock Examples', - getAll() { - return examplePatterns; - }, - getPatternData(id) { - const pats = this.getAll(); - return pats[id]; - }, - exists(id) { - return this.getPatternData(id) != null; - }, -}; - -// break -export const userPattern = { - source: 'user', - collection: 'user', - getAll() { - const patterns = JSON.parse(settingsMap.get().userPatterns); - return patterns; - }, - getPatternData(id) { - const userPatterns = this.getAll(); - return userPatterns[id]; - }, - exists(id) { - return this.getPatternData(id) != null; - }, - - create() { - const newID = createPatternID(); - const code = defaultCode; - const data = { code, created_at: Date.now(), id: newID, collection: this.collection }; - return this.update(newID, data); - }, - update(id, data) { - const userPatterns = this.getAll(); - data = { ...data, id, collection: this.collection }; - setUserPatterns({ ...userPatterns, [id]: data }); - return { id, data }; - }, - duplicate(id) { - const examplePatternData = examplePattern.getPatternData(id); - const data = examplePatternData != null ? examplePatternData : this.getPatternData(id); - const newID = getNextCloneID(id); - return this.update(newID, data); - }, - clearAll() { - if (!confirm(`This will delete all your patterns. Are you really sure?`)) { - return; - } - const viewingPattern = getViewingPattern(); - const examplePatternData = examplePattern.getPatternData(viewingPattern); - setUserPatterns({}); - if (examplePatternData != null) { - return { id: viewingPattern, data: examplePatternData }; - } - // setViewingPattern(null); - setActivePattern(null); - - return { id: null, data: { code: defaultCode, id: null, collection: this.collection } }; - }, - delete(id) { - const userPatterns = this.getAll(); - delete userPatterns[id]; - if (getActivePattern() === id) { - setActivePattern(null); - } - setUserPatterns(userPatterns); - const viewingPattern = getViewingPattern(); - if (viewingPattern === id) { - return { id: null, data: { code: defaultCode } }; - } - return { id: viewingPattern, data: userPatterns[viewingPattern] }; - }, - - rename(id) { - const userPatterns = this.getAll(); - const newID = prompt('Enter new name', id); - const data = userPatterns[id]; - if (newID === null) { - // canceled - return { id, data }; - } - if (userPatterns[newID]) { - alert('Name already taken!'); - return { id, data }; - } - userPatterns[newID] = data; // copy code - delete userPatterns[id]; - - setUserPatterns({ ...userPatterns }); - if (id === getActivePattern()) { - setActivePattern(newID); - } - return { id: newID, data }; - }, -}; - -// 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 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 id = file.name.replace(/\.[^/.]+$/, ''); - userPattern.update(id, { 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..70b64dc1 --- /dev/null +++ b/website/src/user_pattern_utils.mjs @@ -0,0 +1,190 @@ +import { atom } from 'nanostores'; +import { persistentAtom } from '@nanostores/persistent'; +import { useStore } from '@nanostores/react'; + +import { logger } from '@strudel.cycles/core'; +import { nanoid } from 'nanoid'; +import { settingsMap } from './settings.mjs'; +import { supabase } from './repl/util.mjs'; + +export let $publicPatterns = atom([]); +export let $featuredPatterns = atom([]); +const userPatternCollectionName = 'user'; +export let $viewingPatternData = atom({ id: null, code: null, collection: userPatternCollectionName }); + +export const getViewingPatternData = () => { + return $viewingPatternData.get(); +}; + +export const setViewingPatternData = (data) => { + $viewingPatternData.set(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 }); +} + +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'); + } +} + +if (typeof window !== 'undefined') { + loadDBPatterns(); +} + +//pattern that the use is currently viewing in the window +const $viewingPattern = persistentAtom('viewingPattern', '', { listen: false }); +export function setViewingPattern(key) { + $viewingPattern.set(key); +} +export function getViewingPattern() { + return $viewingPattern.get(); +} + +export function useViewingPattern() { + return useStore($viewingPattern); +} +// 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 patterns = { ...userPattern.getAll() }; + const match = Object.entries(patterns).find(([_, pat]) => pat.code === code); + const id = match?.[0]; + if (id != null) { + setActivePattern(id); + setViewingPattern(id); + } +} + +export const setLatestCode = (code) => settingsMap.setKey('latestCode', code); + +const defaultCode = ''; +export const userPattern = { + collection: userPatternCollectionName, + getAll() { + const patterns = JSON.parse(settingsMap.get().userPatterns); + return patterns ?? {}; + }, + getPatternData(id) { + const userPatterns = this.getAll(); + return userPatterns[id]; + }, + exists(id) { + return this.getPatternData(id) != null; + }, + + 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(); + 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 newID = createPatternID(); + return this.update(newID, data); + }, + 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 viewingPattern = getViewingPattern(); + if (viewingPattern === id) { + return { id: null, data: { code: defaultCode } }; + } + return { id: viewingPattern, data: userPatterns[viewingPattern] }; + }, +}; + +function setUserPatterns(obj) { + return settingsMap.setKey('userPatterns', JSON.stringify(obj)); +} + +export const createPatternID = () => { + return nanoid(12); +}; + +export const getNextCloneID = (id) => { + return createPatternID(); +}; + +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, ...JSON.parse(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); +}