diff --git a/README.md b/README.md index 9a169831e..ad320c370 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,8 @@ Each extension is incased in `{}` brackets. Look below on how to copy it. documentation: "page-name", // This is the page name for the documentation you created. // These next ones are optional. You can choose not to include them. + tags: ["new", "graphics"], // Optional. A list of tags to add to your extension. You can put anything here, but it may be adjusted later. + example: "Username/extension.pmp", // Optional. An example project to show off how to use the extension. creatorAlias: "Joe", // Optional. This will not change the creator link, but change the name that links to it. notes: "Additional help by someguy", // Optional. Allows you to note anyone else who helped you or any small info. unstable: false, // Optional. Will add a warning message that your extension is unstable. diff --git a/package-lock.json b/package-lock.json index 805e4e9f4..6a6c06a3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "penguinmod-extensionsgallery", "version": "0.0.1", "dependencies": { + "localforage": "^1.10.0", "markdown-it": "^14.1.0" }, "devDependencies": { @@ -1132,6 +1133,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1152,6 +1159,15 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -1161,6 +1177,15 @@ "uc.micro": "^2.0.0" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", diff --git a/package.json b/package.json index 8ee094f80..3278512a8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "type": "module", "dependencies": { + "localforage": "^1.10.0", "markdown-it": "^14.1.0" } } diff --git a/src/app.html b/src/app.html index 01be09850..4fddeb91d 100644 --- a/src/app.html +++ b/src/app.html @@ -21,7 +21,7 @@ \ No newline at end of file diff --git a/src/lib/Checkbox/icon-enabled.svg b/src/lib/Checkbox/icon-enabled.svg new file mode 100644 index 000000000..ecea4cce4 --- /dev/null +++ b/src/lib/Checkbox/icon-enabled.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/src/lib/Extension/Component.svelte b/src/lib/Extension/Component.svelte index 4a1f63a33..9e0760ed9 100644 --- a/src/lib/Extension/Component.svelte +++ b/src/lib/Extension/Component.svelte @@ -1,41 +1,62 @@ + + + + + \ No newline at end of file diff --git a/src/lib/ThreeStateCheckbox/icon-enabled.svg b/src/lib/ThreeStateCheckbox/icon-enabled.svg new file mode 100644 index 000000000..ecea4cce4 --- /dev/null +++ b/src/lib/ThreeStateCheckbox/icon-enabled.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/src/lib/ThreeStateCheckbox/icon-third.svg b/src/lib/ThreeStateCheckbox/icon-third.svg new file mode 100644 index 000000000..e168efec0 --- /dev/null +++ b/src/lib/ThreeStateCheckbox/icon-third.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/src/lib/extension-loader.js b/src/lib/extension-loader.js new file mode 100644 index 000000000..9bc85a7e5 --- /dev/null +++ b/src/lib/extension-loader.js @@ -0,0 +1,50 @@ +class ExtensionLoader { + static getTargetOrigin() { + const isLocal = location.hostname === "localhost"; + return isLocal ? "http://localhost:3000" : "https://studio.penguinmod.com"; + } + static tryLoadExtension(url) { + const parent = window.opener || window.parent; + if (!parent || parent === window) throw new Error("No parents"); + + const origin = ExtensionLoader.getTargetOrigin(); + parent.postMessage({ + loadExt: `${url}` + }, origin); + } + static handleWindowMessage(e) { + // return false, invalid message; return extension "id", success; throw error, something failed + const intendedOrigin = ExtensionLoader.getTargetOrigin(); + console.log('Recieved message from', e.origin, e); + + if (!e.origin.startsWith(intendedOrigin)) { + console.warn('Message is not from set origin', intendedOrigin, e.origin); + return false; + } + if (!e.data) { + console.warn('No data attached to message'); + return false; + } + if (!e.data.p4) { + console.warn('No data p4 attached to message'); + return false; + } + const eventData = e.data.p4; + if (!eventData.type) { + console.warn('No data type attached to message'); + return false; + } + + // evil win + if (eventData.type === 'success') { + console.log('Loading extension was a success', eventData); + return eventData.extensionId; + } + + // evil fail + console.error('Loading extension failed', eventData); + throw new Error(eventData.error); + } +} + +export default ExtensionLoader; \ No newline at end of file diff --git a/src/lib/extension-tags.js b/src/lib/extension-tags.js new file mode 100644 index 000000000..5a0958632 --- /dev/null +++ b/src/lib/extension-tags.js @@ -0,0 +1,63 @@ +/* +NOTE: This file manages aliases & groupings for tags. +To make a new tag or list it, add it to an extension in `extensions.js` +*/ +export const Tags = [ + // reserved tags (dont add these to an extension) + { + name: "separator", + }, + + // extensiontypes + { + name: "new", + banner: "/icons/tag-banners/new.svg", + group: "extensiontypes", + }, + { + name: "addons", + alias: "Editor Addons", + group: "extensiontypes", + }, + { + name: "expansion", + alias: "Category Expansions", + group: "extensiontypes", + }, + { + name: "collection", + alias: "Extension Collections", + group: "extensiontypes", + }, + + // any other misc ones that just need aliases + { + name: "customtype", + alias: "New Block Type", + }, + { + name: "genai", + alias: "Generative AI", + }, + { + name: "ai", + alias: "Algorithms and AI", + }, + { + name: "large", + alias: "Large Extensions", + }, + { + name: "small", + alias: "Small Extensions", + }, +]; + +export function makeDefaultTag() { + return { + name: "", + // alias: "", + // banner: "/icons/tag-banners/new.svg", + group: "ungrouped", + }; +}; \ No newline at end of file diff --git a/src/lib/state/app.svelte.js b/src/lib/state/app.svelte.js new file mode 100644 index 000000000..336268c2c --- /dev/null +++ b/src/lib/state/app.svelte.js @@ -0,0 +1,6 @@ +const stateApplication = $state({ + // Whether or not the page is coming from the editor (`?editor=true`) + fromEditor: false, +}); + +export default stateApplication; \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5aca7b60e..10fb9dc2f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,12 +4,17 @@ // Components import NavigationBar from "$lib/NavigationBar/Component.svelte"; + import stateApplication from '$lib/state/app.svelte.js'; import stateSearchBar from '$lib/state/searchBar.svelte.js'; let props = $props(); const onSearch = (query) => { stateSearchBar.query = query; }; + + $effect(() => { + stateApplication.fromEditor = String(page.url.searchParams.get("editor")) === "true"; + }); + import { onMount } from 'svelte'; import { page } from '$app/stores'; + import { browser } from "$app/environment"; + import localforage from "localforage"; // Components + import ThreeStateCheckbox from "$lib/ThreeStateCheckbox/ThreeStateCheckbox.svelte"; import Extension from "$lib/Extension/Component.svelte"; + import Checkbox from "$lib/Checkbox/Checkbox.svelte"; import Footer from "$lib/Footer/Component.svelte"; import Logo from "$lib/Logo/Component.svelte"; + import { Tags, makeDefaultTag } from "$lib/extension-tags.js"; + import ExtensionLoader from "$lib/extension-loader.js"; + import stateApplication from "$lib/state/app.svelte.js"; import stateSearchBar from '$lib/state/searchBar.svelte.js'; import extensions from "$lib/extensions.js"; + let messageHandlersAdded = false; const origin = $page.url.origin; const searchable = (text = '') => { text = String(text); @@ -17,21 +26,202 @@ const createExtUrl = (relativeUrl) => { return `${origin}/extensions/${relativeUrl}`; }; - - let showNoExtensionsFound = $state(false); $effect(() => { - const matchingExts = extensions - .filter(extension => searchable(extension.name).includes(stateSearchBar.query)); - showNoExtensionsFound = matchingExts.length <= 0; + if (messageHandlersAdded) return; + if (!stateApplication.fromEditor) return; + console.log("Loaded from editor (supposedly)"); + + window.addEventListener("message", (e) => { + try { + const loadedExtensionId = ExtensionLoader.handleWindowMessage(e); + if (loadedExtensionId) { + const event = new CustomEvent("penguinmod-editor-extension-loaded", { detail: loadedExtensionId }); + document.dispatchEvent(event); + } + } catch (err) { + const event = new CustomEvent("penguinmod-editor-extension-load-failed", { detail: err }); + document.dispatchEvent(event); + } + }); + document.addEventListener("penguinmod-editor-extension-load-failed", (event) => { + const err = event.detail; + console.error("Error loading extension to editor;", err); + + switch (err) { + default: + alert("Failed to import the extension to your project!\nMake sure the \"Choose an Extension\" menu is still open in your project."); + } + }); + messageHandlersAdded = true; + }); + + // create the tag groups for the filter menu + const tagGrouping = {}; + const tagsListShown = $state([]); + onMount(() => { + // first fill tagGrouping so we can group them together properly & sort them + let usedTags = []; + for (const extension of extensions) { + if (extension.tags) { + usedTags = [].concat(usedTags, extension.tags); + } + } + usedTags = [...new Set(usedTags)]; + + // format them to be an object like extension-tags + const formattedTags = []; + for (const tag of usedTags) { + const extensionTag = Tags.find(extTag => extTag.name === tag); + const newTag = { + ...(makeDefaultTag()), + ...(extensionTag ? extensionTag : {}), + name: tag, + }; + if (!newTag.alias) { + // make their alias just be the name with first letter capitalized + const fillerAlias = newTag.name.charAt(0).toUpperCase() + newTag.name.slice(1); + newTag.alias = fillerAlias; + } + + formattedTags.push(newTag); + } + formattedTags.sort((a, b) => a.alias.localeCompare(b.alias)); + + // now group them + for (const tag of formattedTags) { + if (!tagGrouping[tag.group]) tagGrouping[tag.group] = []; + tagGrouping[tag.group].push(tag); + } + + // now fill the list we catually render (use separator for splits between groups) + for (const group in tagGrouping) { + for (const tag of tagGrouping[group]) { + tagsListShown.push(tag); + } + + const separator = makeDefaultTag(); + separator.name = "separator"; + tagsListShown.push(separator); + } + tagsListShown.pop(); + }); + + // searching & filtering + let hasStorageBeenLoaded = false; + let shownExtensions = $state([]); + let filterBarOpened = $state(false); + let showingTestInNewProject = $state(false); + let selectedSorting = $state("none"); + let favoritedExtensions = $state({}); + const tagsSelected = $state({}); + const featuresSelected = $state({ + documentation: 0, + exampleprojects: 0, + warnings: 0, + favorites: 0, + favoritessplit: true, + }); + const toggleFilterBar = () => { + filterBarOpened = !filterBarOpened; + saveToStorage(); + }; + const toggleTestInNewProject = () => { + showingTestInNewProject = !showingTestInNewProject; + saveToStorage(); + }; + const saveToStorage = async () => { + // NOTE: If saveToStorage gets called on the server then this will cause Vite to crash (so dont call it outside of any function) + await localforage.setItem("pm:filter-bar-open", $state.snapshot(filterBarOpened)); + await localforage.setItem("pm:show-test-in-new", $state.snapshot(showingTestInNewProject)); + await localforage.setItem("pm:sorting", $state.snapshot(selectedSorting)); + await localforage.setItem("pm:filters-tags", $state.snapshot(tagsSelected)); + await localforage.setItem("pm:filters-features", $state.snapshot(featuresSelected)); + await localforage.setItem("pm:favorites", $state.snapshot(favoritedExtensions)); + }; + const loadFromStorage = async () => { + const localFilterBarOpened = (await localforage.getItem("pm:filter-bar-open")) || false; + const localShowingTestInNewProject = (await localforage.getItem("pm:show-test-in-new")) || false; + const localSelectedSorting = (await localforage.getItem("pm:sorting")) || "none"; + const localTagsSelected = (await localforage.getItem("pm:filters-tags")) || {}; + const localFeaturesSelected = (await localforage.getItem("pm:filters-features")) || {}; + const localFavoritedExtensions = (await localforage.getItem("pm:favorites")) || {}; + filterBarOpened = localFilterBarOpened; + showingTestInNewProject = localShowingTestInNewProject; + selectedSorting = localSelectedSorting; + for (const key in localTagsSelected) { + tagsSelected[key] = localTagsSelected[key]; + } + for (const key in localFeaturesSelected) { + featuresSelected[key] = localFeaturesSelected[key]; + } + favoritedExtensions = Array.isArray(localFavoritedExtensions) ? {} : localFavoritedExtensions; + + // reproececss + updateExtensionList(); + }; + const updateExtensionList = () => { + // update the list + shownExtensions = [...extensions] + .filter(extension => searchable(extension.name).includes(stateSearchBar.query)) + .filter(extension => Object.values(tagsSelected).some(bool => !!bool) ? (extension.tags || []).find(extTag => tagsSelected[extTag] === true) : true) + .filter(extension => featuresSelected.documentation === 1 ? (!!extension.documentation) : (featuresSelected.documentation === 2 ? !extension.documentation : true)) + .filter(extension => featuresSelected.exampleprojects === 1 ? (!!extension.example) : (featuresSelected.exampleprojects === 2 ? !extension.example : true)) + .filter(extension => featuresSelected.warnings === 1 ? (!!extension.unstable) : (featuresSelected.warnings === 2 ? !extension.unstable : true)) + .filter(extension => featuresSelected.favorites === 1 ? (!!(favoritedExtensions[extension.code])) : (featuresSelected.favorites === 2 ? !(favoritedExtensions[extension.code]) : true)) + ; + + if (selectedSorting === "namedesc" || selectedSorting === "nameasce") { + shownExtensions.sort((a, b) => a.name.localeCompare(b.name)); + } + if (selectedSorting === "creatordesc" || selectedSorting === "creatorasce") { + shownExtensions.sort((a, b) => (a.creatorAlias || a.creator).localeCompare((b.creatorAlias || b.creator))); + } + if (selectedSorting === "reversed" || selectedSorting === "nameasce" || selectedSorting === "creatorasce") { + shownExtensions.reverse(); + } + // split favorites + if (featuresSelected.favoritessplit) { + const notFavorites = shownExtensions.filter(extension => !(favoritedExtensions[extension.code])); + const favorites = shownExtensions.filter(extension => !!(favoritedExtensions[extension.code])); + shownExtensions = [].concat(favorites, notFavorites); + } + + // update localforage + if (hasStorageBeenLoaded) saveToStorage(); + }; + const clearTags = () => { + for (const key in tagsSelected) { + tagsSelected[key] = false; + } + updateExtensionList(); + }; + const onFavoriteClicked = (relUrl) => { + favoritedExtensions[relUrl] = !favoritedExtensions[relUrl]; + + // reproececss + updateExtensionList(); + }; + $effect(() => { + // goive recommendatiaons based on the searched things stateSearchBar.recommendations = []; - if (matchingExts.length <= 5 && !showNoExtensionsFound) { - stateSearchBar.recommendations = matchingExts.slice(0, 2); + if (stateSearchBar.query.length > 0 && shownExtensions.length <= 5 && shownExtensions.length > 0) { + stateSearchBar.recommendations = shownExtensions.slice(0, 2); } const event = new CustomEvent("penguinmod-recommendations-updated"); document.dispatchEvent(event); }); + onMount(async () => { + await loadFromStorage(); + hasStorageBeenLoaded = true; + }); + if (browser) { + document.addEventListener("penguinmod-search-bar-input", () => { + updateExtensionList(); + }); + updateExtensionList(); + }
@@ -40,44 +230,153 @@

PenguinMod Extra Extensions

-
+

See some cool extensions made by other people here.

-

- To use some of these extensions in your projects, click the "Copy URL" - button on an extension and - load it into PenguinMod, - or click the "View" button to create a new project with the extension. -

+ {#if stateApplication.fromEditor} +

+ To add an extension to your project, click the "Add to Project" button. + You can also click the "Copy" button and + load it into PenguinMod + if the former fails. +

+ {:else} +

+ To use some of these extensions in your projects, click the "Copy Link" + button on an extension and + load it into PenguinMod, + or click the "Try it out" button to create a new project with the extension. +

+ {/if} +
-
- - {#each extensions as extension} - {#if searchable(extension.name).includes(stateSearchBar.query)} +
+ +
+ Sort +
+ + {#if stateApplication.fromEditor} + + {/if} +
+
+
+
+

Filters

+ +

Tags

+ {#each tagsListShown as extensionTag} + {#if extensionTag.name === "separator"} +
+ {:else} + + {/if} + {:else} +

No tags currently exist.

+ {/each} + + +

Features

+ + + + +
+ + + +
+
+
+ + {#each shownExtensions as extension} {extension.description} - {/if} - {/each} - {#if showNoExtensionsFound} -

No extensions found under that search query.

- {/if} + {:else} +

No extensions match your selected filters.

+ {/each} +
+
+

Note: Some extensions may be added to the Extension Gallery in - PenguinMod Studio.
If you cannot find an extension that was + PenguinMod Studio. +
+ If you cannot find an extension that was previously listed here, check there.

@@ -90,8 +389,21 @@ color: white; } + label { + display: flex; + flex-direction: row; + align-items: center; + + user-select: none; + } + :global(*[data-penguinmodsvelteui-threestatecheckbox="true"]), + :global(*[data-penguinmodsvelteui-checkbox="true"]) { + margin-right: 4px !important; + } + .top { width: 100%; + display: flex; flex-direction: column; align-items: center; @@ -100,26 +412,139 @@ display: flex; align-items: center; flex-direction: row; + font-size: 1.35em; } - .main { + .buffer { + width: 80%; + margin-left: 10%; + + display: flex; + flex-direction: column; + } + + .extension-list-controls { + width: 80%; + height: 40px; + margin-left: 10%; + + display: flex; + flex-direction: row; + align-items: center; + } + .extension-list-controls-sorting-selector-image-container-div, + .extension-list-controls button { + display: block; + width: 40px; + height: 40px; + border: 0; + + border-radius: 8px; + background: transparent; + } + .extension-list-controls button { + cursor: pointer; + } + .extension-list-controls button:active { + background-color: rgba(0, 0, 0, 0.125); + } + :global(body.dark-mode) .extension-list-controls button:active { + background-color: rgba(255, 255, 255, 0.125); + } + .extension-list-controls img { + width: 100%; + height: 100%; + + filter: brightness(0.2); + } + :global(body.dark-mode) .extension-list-controls img { + filter: brightness(1); + } + .extension-list-controls select { + height: 32px; + margin: 0 8px; + + border-radius: 4px; + } + + .extension-list-container { width: 80%; margin-left: 10%; + display: flex; flex-direction: column; } + .extension-list-container[data-filteropen="true"] { + width: 100%; + margin-left: initial; + } + .extension-list-bars { + width: 100%; + + display: flex; + flex-direction: row; + } + .extension-list-filters { + display: none; + width: calc(20% - (16px + 16px + 2px)); + border: 1px solid rgba(0, 0, 0, 0.25); + margin: 8px; + padding: 0px 8px; + border-radius: 16px; + } + :global(body.dark-mode) .extension-list-filters { + border-color: rgba(255, 255, 255, 0.75); + } + .extension-list-filters[data-filteropen="true"] { + display: initial; + } + .extension-list-filters-label { + display: block; + margin-top: 8px; + + font-style: italic; + opacity: 0.7; + } + .extension-list-filters-clear { + border-color: rgba(0, 0, 0, 0.25); + border-radius: 3px; + background: dodgerblue; + color: white; + font-weight: bold; + font-size: 16px; + + cursor: pointer; + } .extension-list { width: 100%; + display: flex; flex-direction: row; flex-wrap: wrap; justify-content: center; } + .extension-list[data-filteropen="true"] { + width: calc(100% - 20%); + } + @media screen and (max-width: 920px) { + .extension-list-filters { + width: calc(100% - (16px + 16px + 2px)); + } + .extension-list-bars { + flex-direction: column; + } + .extension-list[data-filteropen="true"] { + width: 100%; + } + } + .no-exts { + height: 1em; padding: 8px 32px; border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; } :global(body.dark-mode) .no-exts { diff --git a/static/examples/projects/JeremyGamer13/test.pmp b/static/examples/projects/JeremyGamer13/test.pmp new file mode 100644 index 000000000..e0326d468 Binary files /dev/null and b/static/examples/projects/JeremyGamer13/test.pmp differ diff --git a/static/icons/favorite-filled.svg b/static/icons/favorite-filled.svg new file mode 100644 index 000000000..5bba68830 --- /dev/null +++ b/static/icons/favorite-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/icons/favorite-outline.svg b/static/icons/favorite-outline.svg new file mode 100644 index 000000000..ad97d149f --- /dev/null +++ b/static/icons/favorite-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/icons/filter.svg b/static/icons/filter.svg new file mode 100644 index 000000000..4466ebc7f --- /dev/null +++ b/static/icons/filter.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/static/icons/sort.svg b/static/icons/sort.svg new file mode 100644 index 000000000..8cb8c3871 --- /dev/null +++ b/static/icons/sort.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/icons/tag-banners/new.svg b/static/icons/tag-banners/new.svg new file mode 100644 index 000000000..3bc6959ff --- /dev/null +++ b/static/icons/tag-banners/new.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/icons/test-disabled.svg b/static/icons/test-disabled.svg new file mode 100644 index 000000000..3d7fabcc3 --- /dev/null +++ b/static/icons/test-disabled.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/static/icons/test-enabled.svg b/static/icons/test-enabled.svg new file mode 100644 index 000000000..0f84103cb --- /dev/null +++ b/static/icons/test-enabled.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + \ No newline at end of file