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)}
+
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.