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:
Misode
2022-03-19 19:26:39 +01:00
committed by GitHub
parent 03e9c53d70
commit a5a08fc935
10 changed files with 212 additions and 14 deletions

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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)
}, [])

View File

@@ -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>
</>
}

View 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')
}

View File

@@ -1,3 +1,4 @@
export * from './Changelogs'
export * from './DataFetcher'
export * from './Schemas'
export * from './Sharing'

View File

@@ -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",

View File

@@ -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;
}