import { type FrameContext } from '../../types/context.js'
import { type Frame as FrameType, type RequestBody } from '../types.js'
import { Data } from './Data.js'
import { Frame } from './Frame.js'
import { Metrics } from './Metrics.js'
import { Navigator } from './Navigator.js'
import { QuickLinks } from './QuickLinks.js'
import { Tabs } from './Tabs.js'
import { Timeline } from './Timeline.js'
export type PreviewProps = {
data: Data
routes: readonly string[]
}
type Data = {
id: string
metrics: {
speed: number
}
response: {
success: boolean
status: number
statusText: string
error?: string | undefined
}
timestamp: number
} & (
| {
type: 'initial'
method: 'get'
context: FrameContext
frame: FrameType
metrics: {
htmlSize: number
imageSize: number
}
url: string
}
| {
type: 'action'
method: 'post'
body: RequestBody
context: FrameContext
frame: FrameType
metrics: {
htmlSize: number
imageSize: number
}
}
| {
type: 'redirect'
method: 'post'
body: RequestBody
response: {
location?: string
}
}
)
export function Preview(props: PreviewProps) {
return (
{
if (data.state !== 'completed') return
const { state: _, ...rest } = data
this.user = rest
})
.catch(console.error)
} catch (e) {
console.log({ e })
}
this.restoreState()
.finally(() => {
$watch('dataKey', (dataKey) => this.saveState({ dataKey }))
$watch('dataMap', (dataMap) => this.saveState({ dataMap }))
$watch('logIndex', (logIndex) => this.saveState({ logIndex }))
$watch('logs', (logs) => this.saveState({ logs }))
$watch('overrides', (overrides) => this.saveState({ overrides }))
$watch('stackIndex', (stackIndex) => this.saveState({ stackIndex }))
$watch('stack', (stack) => this.saveState({ stack }))
this.mounted = true
})
},
get data() {
return this.dataMap[this.dataKey]
},
routes: ${JSON.stringify(props.routes)},
get frame() {
const frame = this.data.frame
const contextString = this.toSearchParams(this.data.context).toString()
return {
...frame,
image: frame.image.replace('_frog_image', contextString),
imageUrl: frame.imageUrl.replace('_frog_imageUrl', contextString),
}
},
dataKey: '${props.data.id}',
dataMap: { '${props.data.id}': ${JSON.stringify(props.data)} },
inputText: '',
logIndex: -1,
logs: ['${props.data.id}'],
mounted: false,
stackIndex: 0,
stack: ['${props.data.id}'],
tab: $persist('request'),
user: $persist(null),
overrides: {
userFid: 1,
castFid: 1,
castHash: '0x0000000000000000000000000000000000000000',
},
async getFrame(url, options = { replaceLogs: false, skipLogs: false }) {
const json = await fetch(this.parsePath(url) + '/dev/frame', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then((response) => response.json())
const { data, routes } = json
const id = data.id
this.dataMap[id] = data
this.routes = routes
this.logIndex = -1
if (!options.skipLogs)
this.logs = options.replaceLogs ? [id] : [...this.logs, id]
return json.data
},
async postFrameAction(body, options = { skipLogs: false }) {
const url = this.parsePath(body.url)
const json = await fetch(url + '/dev/frame/action', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
}).then((response) => response.json())
const { data, routes } = json
const id = data.id
this.dataMap[id] = data
this.routes = routes
this.logIndex = -1
if (!options.skipLogs)
this.logs = [...this.logs, id]
return data
},
async postFrameRedirect(body, options = { skipLogs: false }) {
const url = this.parsePath(body.url)
const json = await fetch(url + '/dev/frame/redirect', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
}).then((response) => response.json())
const previousData = this.dataMap[this.logs.at(-1)]
const data = {
context: previousData.context,
frame: previousData.frame,
...json,
}
const id = json.id
this.dataMap[id] = data
this.logIndex = -1
if (!options.skipLogs)
this.logs = [...this.logs, id]
return data
},
async fetchAuthCode() {
const url = this.parsePath(this.data.body ? this.data.body.url : this.data.url)
const json = await fetch(url + '/dev/frame/auth/code', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then((response) => response.json())
return json
},
async fetchAuthStatus(token) {
const url = this.parsePath(this.data.body ? this.data.body.url : this.data.url)
const json = await fetch(url + '/dev/frame/auth/status/' + token, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}).then((response) => response.json())
return json
},
async logout(body) {
const url = this.parsePath(this.data.body ? this.data.body.url : this.data.url)
const json = await fetch(url + '/dev/frame/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).then((response) => response.json())
this.user = null
return json
},
formatFileSize(sizeInBytes) {
if (sizeInBytes < 1024) return sizeInBytes + 'b'
if (sizeInBytes < 1024 * 1024) return (sizeInBytes / 1024).toFixed(2) + 'kb'
return (sizeInBytes / (1024 * 1024)).toFixed(2) + 'mb'
},
formatSpeed(speed) {
if (speed < 1000) return (Math.round((speed + Number.EPSILON) * 100) / 100).toFixed(2) + 'ms'
if (speed % 1 === 0) return (speed / 100) + 's'
return (speed / 1000).toFixed(2) + 's'
},
formatTime(time) {
return new Date(time).toLocaleTimeString()
},
formatUrl(url) {
let urlObj = new URL(url)
urlObj.search = ''
const urlString = urlObj.toString().replace(/https?:\\/\\//, '')
return urlString.endsWith('/') ? urlString.slice(0, -1) : urlString
},
cache: {},
async getCodeHtml(code, lang) {
const key = lang + "_" + code
if (this.cache[key]) return this.cache[key]
const theme = createCssVariablesTheme({
name: 'css-variables',
variablePrefix: '--shiki-',
variableDefaults: {},
fontStyle: true,
})
const highlighter = await getHighlighter({
langs: ['html', 'json'],
themes: [theme],
})
const html = await highlighter.codeToHtml(code, {
lang,
theme: 'css-variables',
})
this.cache[key] = html
return html
},
saveState(state) {
if (!this.mounted) return
const nextState = {
dataKey: this.dataKey,
dataMap: this.dataMap,
logs: this.logs,
logIndex: this.logIndex,
overrides: this.overrides,
stack: this.stack,
stackIndex: this.stackIndex,
...state,
}
const compressed = LZString.compressToEncodedURIComponent(JSON.stringify(nextState))
window.history.replaceState(null, null, '#state/' + compressed);
},
async restoreState() {
if (!location.hash.startsWith('#state')) {
const userFid = this.user?.userFid
if (userFid) this.overrides = { ...this.overrides, userFid }
return
}
const state = location.hash.replace('#state/', '').trim()
try {
let restored = LZString.decompressFromEncodedURIComponent(state)
// Fallback incase there is an extra level of decoding:
// https://gitter.im/Microsoft/TypeScript?at=5dc478ab9c39821509ff189a
if (!restored) restored = LZString.decompressFromEncodedURIComponent(decodeURIComponent(state))
restored = JSON.parse(restored)
this.dataKey = restored.dataKey
this.dataMap = restored.dataMap
this.logs = restored.logs
this.logIndex =
restored.logIndex === restored.logs.length - 1 ? -1 : restored.logIndex
this.overrides = restored.overrides
this.stack = restored.stack
this.stackIndex = restored.stackIndex
// Refetch current frame if no other frame is selected or back in the stack (e.g. hit back button to previous frame in history)
// This allows you to make changes to the frame in code and see updates immediately
const endOfStack = restored.stackIndex === restored.stack.length - 1
const endOfLogs = restored.logIndex === -1 || restored.logIndex === restored.logs.length - 1
const nextData = restored.dataMap[restored.dataKey]
if (endOfStack && endOfLogs && nextData) {
let json
switch (nextData?.type) {
case 'initial': {
json = await this.getFrame(nextData.url, { skipLogs: true })
break
}
case 'action': {
json = await this.postFrameAction(nextData.body, { skipLogs: true })
break
}
case 'redirect': {
json = await this.postFrameRedirect(nextData.body, { skipLogs: true })
break
}
}
this.logs = this.logs.slice(0, this.logs.length - 1).concat(json.id)
this.dataKey = json.id
}
} catch (error) {
console.log('failed to restore state:', error.message)
history.replaceState(null, null, location.pathname);
}
},
getCookie(name) {
const cookieArr = document.cookie.split(";")
for (let i = 0; i < cookieArr.length; i++) {
var cookie = cookieArr[i]
while (cookie.charAt(0) == " ") {
cookie = cookie.substring(1, cookie.length)
}
if (cookie.indexOf(name) == 0)
return cookie.substring(name.length + 1, cookie.length)
}
return null
},
parsePath(path_) {
let path = path_.split('?')[0]
if (path.endsWith('/')) path = path.slice(0, -1)
return path
},
toSearchParams(object) {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(object)) {
const encoded = (() => {
if (typeof value === 'string') return encodeURIComponent(value)
if (typeof value === 'number') return value.toString()
if (typeof value === 'boolean') return value.toString()
if (typeof value === 'object' && value !== null) {
return encodeURIComponent(
Array.isArray(value)
? '#A_' + value.join(',')
: '#O_' + JSON.stringify(value),
)
}
return undefined
})()
if (encoded) params.set(key, encoded)
}
return params
},
}`}
class="flex flex-col container md:grid md:container gap-4 md:gap-6"
style={{
maxWidth: '1512px',
marginLeft: 'auto',
marginRight: 'auto',
}}
>
)
}