diff --git a/package-lock.json b/package-lock.json index e99585a8..b8c1da8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "deepslate-rs": "^0.1.6", "howler": "^2.2.3", "js-yaml": "^3.14.1", + "lz-string": "^1.4.4", "marked": "^4.0.10", "rfdc": "^1.3.0", "sourcemapped-stacktrace": "^1.1.11" @@ -37,6 +38,7 @@ "@types/google.analytics": "0.0.40", "@types/howler": "^2.2.4", "@types/js-yaml": "^4.0.4", + "@types/lz-string": "^1.3.34", "@types/marked": "^4.0.1", "@types/seedrandom": "^2.4.28", "@typescript-eslint/eslint-plugin": "^4.25.0", @@ -625,6 +627,12 @@ "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "dev": true }, + "node_modules/@types/lz-string": { + "version": "1.3.34", + "resolved": "https://registry.npmjs.org/@types/lz-string/-/lz-string-1.3.34.tgz", + "integrity": "sha512-j6G1e8DULJx3ONf6NdR5JiR2ZY3K3PaaqiEuKYkLQO0Czfi1AzrtjfnfCROyWGeDd5IVMKCwsgSmMip9OWijow==", + "dev": true + }, "node_modules/@types/marked": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.0.1.tgz", @@ -3237,6 +3245,14 @@ "node": ">=10" } }, + "node_modules/lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/marked": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz", @@ -5018,6 +5034,12 @@ "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "dev": true }, + "@types/lz-string": { + "version": "1.3.34", + "resolved": "https://registry.npmjs.org/@types/lz-string/-/lz-string-1.3.34.tgz", + "integrity": "sha512-j6G1e8DULJx3ONf6NdR5JiR2ZY3K3PaaqiEuKYkLQO0Czfi1AzrtjfnfCROyWGeDd5IVMKCwsgSmMip9OWijow==", + "dev": true + }, "@types/marked": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.0.1.tgz", @@ -6950,6 +6972,11 @@ "yallist": "^4.0.0" } }, + "lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=" + }, "marked": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz", diff --git a/package.json b/package.json index 326ad4c2..39fd3442 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "deepslate-rs": "^0.1.6", "howler": "^2.2.3", "js-yaml": "^3.14.1", + "lz-string": "^1.4.4", "marked": "^4.0.10", "rfdc": "^1.3.0", "sourcemapped-stacktrace": "^1.1.11" @@ -43,6 +44,7 @@ "@types/google.analytics": "0.0.40", "@types/howler": "^2.2.4", "@types/js-yaml": "^4.0.4", + "@types/lz-string": "^1.3.34", "@types/marked": "^4.0.1", "@types/seedrandom": "^2.4.28", "@typescript-eslint/eslint-plugin": "^4.25.0", diff --git a/src/app/Utils.ts b/src/app/Utils.ts index 653b56fc..7fd29fd5 100644 --- a/src/app/Utils.ts +++ b/src/app/Utils.ts @@ -88,7 +88,7 @@ export function setSeachParams(modifications: Record else searchParams.set(key, value) }) const search = Array.from(searchParams).map(([key, value]) => - `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + `${encodeURIComponent(key)}=${encodeURIComponent(value).replaceAll('%2F', '/')}`) route(`${newPath ? cleanUrl(newPath) : getPath(url)}${search.length === 0 ? '' : `?${search.join('&')}`}`, true) } diff --git a/src/app/components/Btn.tsx b/src/app/components/Btn.tsx index 2a1ed1ab..69e7fb8c 100644 --- a/src/app/components/Btn.tsx +++ b/src/app/components/Btn.tsx @@ -6,11 +6,12 @@ type BtnProps = { active?: boolean, tooltip?: string, tooltipLoc?: 'se' | 'sw' | 'nw', + showTooltip?: boolean, class?: string, onClick?: (event: MouseEvent) => unknown, } export function Btn({ icon, label, active, class: clazz, tooltip, tooltipLoc, onClick }: BtnProps) { - return
+ return
{icon && Octicon[icon]} {label && {label}}
diff --git a/src/app/contexts/Version.tsx b/src/app/contexts/Version.tsx index 96eb0236..2db2fcab 100644 --- a/src/app/contexts/Version.tsx +++ b/src/app/contexts/Version.tsx @@ -12,7 +12,7 @@ const VERSION_PARAM = 'version' interface Version { version: VersionId, - changeVersion: (version: VersionId) => unknown, + changeVersion: (version: VersionId, store?: boolean) => unknown, } const Version = createContext({ version: '1.18.2', @@ -34,12 +34,14 @@ export function VersionProvider({ children }: { children: ComponentChildren }) { } }, [version, targetVersion]) - const changeVersion = useCallback((version: VersionId) => { + const changeVersion = useCallback((version: VersionId, store = true) => { if (getSearchParams(getCurrentUrl()).has(VERSION_PARAM)) { setSeachParams({ version }) } - Analytics.setVersion(version) - Store.setVersion(version) + if (store) { + Analytics.setVersion(version) + Store.setVersion(version) + } setVersion(version) }, []) diff --git a/src/app/pages/Generator.tsx b/src/app/pages/Generator.tsx index cd81c11a..5fc7fbd6 100644 --- a/src/app/pages/Generator.tsx +++ b/src/app/pages/Generator.tsx @@ -8,8 +8,8 @@ import { useLocale, useProject, useTitle, useVersion } from '../contexts' import { useActiveTimeout, useModel } from '../hooks' import { getOutput } from '../schema/transformOutput' import type { BlockStateRegistry, VersionId } from '../services' -import { checkVersion, fetchPreset, getBlockStates, getCollections, getModel } from '../services' -import { getGenerator, getSearchParams, message, setSeachParams } from '../Utils' +import { checkVersion, fetchPreset, getBlockStates, getCollections, getModel, getSnippet, shareSnippet, SHARE_KEY } from '../services' +import { cleanUrl, deepEqual, getGenerator, getSearchParams, message, setSeachParams } from '../Utils' interface Props { default?: true, @@ -45,14 +45,30 @@ export function Generator({}: Props) { const searchParams = getSearchParams(getCurrentUrl()) const currentPreset = searchParams.get('preset') + const sharedSnippetId = searchParams.get(SHARE_KEY) useEffect(() => { if (model && currentPreset) { loadPreset(currentPreset).then(preset => { - model?.reset(DataModel.wrapLists(preset), false) - setSeachParams({ version, preset: currentPreset }) + model.reset(DataModel.wrapLists(preset), false) + setSeachParams({ version, preset: currentPreset, [SHARE_KEY]: undefined }) }) + } else if (model && sharedSnippetId) { + getSnippet(sharedSnippetId).then(s => loadSnippet(model, s)) } - }, [currentPreset]) + }, [currentPreset, sharedSnippetId]) + + const loadSnippet = (model: DataModel, snippet: any) => { + if (snippet.version && snippet.version !== version) { + changeVersion(snippet.version, false) + } + if (snippet.type && snippet.type !== gen.id) { + const snippetGen = config.generators.find(g => g.id === snippet.type) + if (snippetGen) { + route(`${cleanUrl(snippetGen.url)}?${SHARE_KEY}=${snippet.id}`) + } + } + model.reset(DataModel.wrapLists(snippet.data), false) + } const [model, setModel] = useState(null) const [blockStates, setBlockStates] = useState(null) @@ -67,6 +83,9 @@ export function Generator({}: Props) { if (currentPreset) { const preset = await loadPreset(currentPreset) m.reset(DataModel.wrapLists(preset), false) + } else if (sharedSnippetId) { + const snippet = await getSnippet(sharedSnippetId) + loadSnippet(m, snippet) } setModel(m) }) @@ -75,7 +94,7 @@ export function Generator({}: Props) { const [dirty, setDirty] = useState(false) useModel(model, () => { - setSeachParams({ version: undefined, preset: undefined }) + setSeachParams({ version: undefined, preset: undefined, [SHARE_KEY]: undefined }) setError(null) setDirty(true) }) @@ -178,7 +197,7 @@ export function Generator({}: Props) { const selectPreset = (id: string) => { Analytics.generatorEvent('load-preset', id) - setSeachParams({ version, preset: id }) + setSeachParams({ version, preset: id, [SHARE_KEY]: undefined }) } const loadPreset = async (id: string) => { @@ -197,6 +216,48 @@ export function Generator({}: Props) { } } + const [shareUrl, setShareUrl] = useState(undefined) + const [shareShown, setShareShown] = useState(false) + const [shareCopyActive, shareCopySuccess] = useActiveTimeout({ cooldown: 3000 }) + const share = () => { + if (shareShown) { + setShareShown(false) + return + } + if (currentPreset) { + setShareUrl(`${location.protocol}//${location.host}/${gen.url}/?version=${version}&preset=${currentPreset}`) + setShareShown(true) + copySharedId() + } else if (model && blockStates) { + const output = getOutput(model, blockStates) + if (deepEqual(output, model.schema.default())) { + setShareUrl(`${location.protocol}//${location.host}/${gen.url}/`) + setShareShown(true) + } else { + shareSnippet(gen.id, version, output) + .then(url => { + setShareUrl(url) + setShareShown(true) + }) + .catch(e => { + if (e instanceof Error) { + setError(e) + } + }) + } + } + } + const copySharedId = () => { + navigator.clipboard.writeText(shareUrl ?? '') + shareCopySuccess() + } + useEffect(() => { + if (!shareCopyActive) { + setShareUrl(undefined) + setShareShown(false) + } + }, [shareCopyActive]) + const [sourceShown, setSourceShown] = useState(window.innerWidth > 820) const [doCopy, setCopy] = useState(0) const [doDownload, setDownload] = useState(0) @@ -228,7 +289,7 @@ export function Generator({}: Props) { const [previewShown, setPreviewShown] = useState(false) const hasPreview = HasPreview.includes(gen.id) && !(gen.id === 'worldgen/configured_feature' && checkVersion(version, '1.18')) if (previewShown && !hasPreview) setPreviewShown(false) - let actionsShown = 1 + let actionsShown = 2 if (hasPreview) actionsShown += 1 if (sourceShown) actionsShown += 2 @@ -282,6 +343,9 @@ export function Generator({}: Props) {
{previewShown ? Octicon.x_circle : Octicon.play}
+
+ {Octicon.link} +
{Octicon.download}
@@ -298,5 +362,9 @@ export function Generator({}: Props) {
+
+ + +
} diff --git a/src/app/services/Sharing.ts b/src/app/services/Sharing.ts new file mode 100644 index 00000000..8286d3cf --- /dev/null +++ b/src/app/services/Sharing.ts @@ -0,0 +1,58 @@ +import lz from 'lz-string' +import config from '../../config.json' +import type { VersionId } from './Schemas' + +const API_PREFIX = 'https://z15g7can.directus.app/items' +export const SHARE_KEY = 'share' + +const ShareCache = new Map() + +export async function shareSnippet(type: string, version: VersionId, jsonData: any) { + try { + const data = lz.compressToBase64(JSON.stringify(jsonData)) + const raw = btoa(JSON.stringify(jsonData)) + console.log('Compression rate', raw.length / data.length) + const body = JSON.stringify({ data, type, version }) + let id = ShareCache.get(body) + if (!id) { + const snippet = await fetchApi('/snippets', body) + ShareCache.set(body, snippet.id) + id = snippet.id as string + } + const gen = config.generators.find(g => g.id === type)! + return `${location.protocol}//${location.host}/${gen.url}/?${SHARE_KEY}=${id}` + } catch (e) { + if (e instanceof Error) { + e.message = `Error creating share link: ${e.message}` + } + throw e + } +} + +export async function getSnippet(id: string) { + try { + const snippet = await fetchApi(`/snippets/${id}`) + return { + ...snippet, + data: JSON.parse(lz.decompressFromBase64(snippet.data) ?? '{}'), + } + } catch (e) { + if (e instanceof Error) { + e.message = `Error loading shared content: ${e.message}` + } + throw e + } +} + +async function fetchApi(url: string, body?: string) { + const res = await fetch(API_PREFIX + url, body ? { + method: 'post', + headers: { 'Content-Type': 'application/json' }, + body, + } : undefined) + const data = await res.json() + if (data.data) { + return data.data + } + throw new Error(data.errors?.[0]?.message ?? 'Unknown error') +} diff --git a/src/app/services/index.ts b/src/app/services/index.ts index 2a6c188e..64969fee 100644 --- a/src/app/services/index.ts +++ b/src/app/services/index.ts @@ -1,3 +1,4 @@ export * from './Changelogs' export * from './DataFetcher' export * from './Schemas' +export * from './Sharing' diff --git a/src/locales/en.json b/src/locales/en.json index 5a5b2337..500b084a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -11,6 +11,7 @@ "collapse_all": "Hold %0% to collapse all", "configure_layers": "Configure layers", "copy": "Copy", + "copy_share": "Copy share link", "copied": "Copied!", "copy_context": "Copy context", "dimension_type": "Dimension Type", diff --git a/src/styles/global.css b/src/styles/global.css index 40f4c4a2..a91ae54c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -421,6 +421,41 @@ main.has-preview { -ms-user-select: none; } +.popup-share { + position: fixed; + display: flex; + width: 40vw; + min-height: 108px; + left: 100%; + bottom: 0; + z-index: 3; + padding: 12px; + background-color: var(--background-3); + box-shadow: 0 0 7px -3px #000; + color: var(--text-2); + transition: transform 0.3s; + border-radius: 6px 0 0 0; +} + +.popup-share.shown { + transform: translateX(-100%); +} + +.popup-share > input { + height: 32px; + background-color: var(--background-1); + color: var(--text-2); + border: none; + border-radius: 6px; + padding: 7px 11px; + margin-right: 8px; + width: 100%; +} + +.popup-share > .btn.active { + fill: var(--accent-success); +} + .btn { display: flex; align-items: center; @@ -722,12 +757,15 @@ main.has-preview { opacity: 0; } +.tooltipped.tip-shown::before, +.tooltipped.tip-shown::after, .tooltipped:not([disabled]):hover::before, .tooltipped:not([disabled]):hover::after { display: inline-block; animation: tooltip-appear 0.1s ease-in 0.4s forwards; } +.tooltipped.tip-shown::after, .tooltipped:not([disabled]):hover::after { box-shadow: 0 1px 3px 0 #0007; }