mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-24 23:56:51 +00:00
Implement link sharing (#213)
* Implement link sharing * Share default * Compress and base64 encode data * Better error messages * Fix build * Only change version when it's different
This commit is contained in:
@@ -88,7 +88,7 @@ export function setSeachParams(modifications: Record<string, string | undefined>
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <div class={`btn${active ? ' active' : ''}${clazz ? ` ${clazz}` : ''}${tooltip ? ` tooltipped tip-${tooltipLoc ?? 'sw'}` : ''}`} onClick={onClick} aria-label={tooltip}>
|
||||
return <div class={`btn${active ? ' active' : ''}${clazz ? ` ${clazz}` : ''}${tooltip ? ` tooltipped tip-${tooltipLoc ?? 'sw'}` : ''}${active ? ' tip-shown' : ''}`} onClick={onClick} aria-label={tooltip}>
|
||||
{icon && Octicon[icon]}
|
||||
{label && <span>{label}</span>}
|
||||
</div>
|
||||
|
||||
@@ -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>({
|
||||
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)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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<DataModel | null>(null)
|
||||
const [blockStates, setBlockStates] = useState<BlockStateRegistry | null>(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<string | undefined>(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) {
|
||||
<div class={`popup-action action-preview${hasPreview ? ' shown' : ''} tooltipped tip-nw`} aria-label={locale(previewShown ? 'hide_preview' : 'show_preview')} onClick={togglePreview}>
|
||||
{previewShown ? Octicon.x_circle : Octicon.play}
|
||||
</div>
|
||||
<div class={'popup-action action-share shown tooltipped tip-nw'} aria-label={locale('share')} onClick={share}>
|
||||
{Octicon.link}
|
||||
</div>
|
||||
<div class={`popup-action action-download${sourceShown ? ' shown' : ''} tooltipped tip-nw`} aria-label={locale('download')} onClick={downloadSource}>
|
||||
{Octicon.download}
|
||||
</div>
|
||||
@@ -298,5 +362,9 @@ export function Generator({}: Props) {
|
||||
<div class={`popup-source${sourceShown ? ' shown' : ''}`}>
|
||||
<SourcePanel {...{model, blockStates, doCopy, doDownload, doImport}} name={gen.schema ?? 'data'} copySuccess={copySuccess} onError={setError} />
|
||||
</div>
|
||||
<div class={`popup-share${shareShown ? ' shown' : ''}`}>
|
||||
<TextInput value={shareUrl} readonly />
|
||||
<Btn icon={shareCopyActive ? 'check' : 'clippy'} onClick={copySharedId} tooltip={locale(shareCopyActive ? 'copied' : 'copy_share')} tooltipLoc="nw" active={shareCopyActive} showTooltip={shareCopyActive} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
58
src/app/services/Sharing.ts
Normal file
58
src/app/services/Sharing.ts
Normal file
@@ -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<string, string>()
|
||||
|
||||
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')
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Changelogs'
|
||||
export * from './DataFetcher'
|
||||
export * from './Schemas'
|
||||
export * from './Sharing'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user