156 lines
5.0 KiB
JavaScript
156 lines
5.0 KiB
JavaScript
// scrot PWA — thin client mirroring cli/scrot-clip-push, scrot-clip-pull,
|
|||
|
|
// scrot-shot. Token lives in localStorage (used for every fetch here) and
|
||
|
|
// mirrored into a cookie (read server-side by /manifest.webmanifest to bake
|
||
|
|
// the token into the share_target action — see server/src/manifest.ts).
|
||
|
|
|
||
|
|
const TOKEN_KEY = 'scrot_token'
|
||
|
|
|
||
|
|
const $ = (id) => document.getElementById(id)
|
||
|
|
const toastEl = $('toast')
|
||
|
|
const onboardEl = $('onboard')
|
||
|
|
const tokenInput = $('tokenInput')
|
||
|
|
|
||
|
|
function getToken() {
|
||
|
|
return localStorage.getItem(TOKEN_KEY) || ''
|
||
|
|
}
|
||
|
|
|
||
|
|
function setToken(token) {
|
||
|
|
localStorage.setItem(TOKEN_KEY, token)
|
||
|
|
// 1 year, Lax is enough since this cookie is only ever read by our own
|
||
|
|
// manifest route, never sent cross-site meaningfully.
|
||
|
|
document.cookie = `scrot_token=${encodeURIComponent(token)}; path=/; max-age=31536000; samesite=lax`
|
||
|
|
}
|
||
|
|
|
||
|
|
function toast(message) {
|
||
|
|
toastEl.textContent = message
|
||
|
|
toastEl.style.display = 'block'
|
||
|
|
clearTimeout(toast._t)
|
||
|
|
toast._t = setTimeout(() => { toastEl.style.display = 'none' }, 4000)
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshOnboardVisibility() {
|
||
|
|
const has = !!getToken()
|
||
|
|
onboardEl.style.display = has ? 'none' : 'block'
|
||
|
|
if (has) tokenInput.value = getToken()
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- onboarding: pick up #t=<token> from cli/scrot-onboard's QR/link ---
|
||
|
|
function consumeHashToken() {
|
||
|
|
const match = location.hash.match(/(?:^#|&)t=([^&]+)/)
|
||
|
|
if (!match) return false
|
||
|
|
const token = decodeURIComponent(match[1])
|
||
|
|
setToken(token)
|
||
|
|
history.replaceState(null, '', location.pathname + location.search)
|
||
|
|
toast('Device token saved. Add scrot to your home screen to enable "Share to scrot".')
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
const hadHashToken = consumeHashToken()
|
||
|
|
if (!hadHashToken && getToken()) setToken(getToken()) // re-assert cookie in case it expired
|
||
|
|
refreshOnboardVisibility()
|
||
|
|
|
||
|
|
$('saveToken').addEventListener('click', () => {
|
||
|
|
const token = tokenInput.value.trim()
|
||
|
|
if (!token) return
|
||
|
|
setToken(token)
|
||
|
|
refreshOnboardVisibility()
|
||
|
|
toast('Device token saved.')
|
||
|
|
})
|
||
|
|
|
||
|
|
$('editToken').addEventListener('click', () => {
|
||
|
|
onboardEl.style.display = 'block'
|
||
|
|
tokenInput.focus()
|
||
|
|
})
|
||
|
|
|
||
|
|
// --- share-target redirect landing ---
|
||
|
|
const params = new URLSearchParams(location.search)
|
||
|
|
if (params.get('shared') === 'clip') toast('Shared text saved as a clip.')
|
||
|
|
if (params.get('shared') === 'shot') toast('Shared screenshot uploaded.')
|
||
|
|
if (params.get('shared') === 'empty') toast('Nothing shareable in that share (no text or image).')
|
||
|
|
if (params.has('shared')) history.replaceState(null, '', location.pathname)
|
||
|
|
|
||
|
|
// --- push clip ---
|
||
|
|
$('pasteFromClipboard').addEventListener('click', async () => {
|
||
|
|
try {
|
||
|
|
$('clipText').value = await navigator.clipboard.readText()
|
||
|
|
} catch {
|
||
|
|
toast('Clipboard read not available — paste manually instead.')
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
$('pushClip').addEventListener('click', async () => {
|
||
|
|
const text = $('clipText').value
|
||
|
|
if (!text) return
|
||
|
|
const result = $('clipResult')
|
||
|
|
result.textContent = 'Sending…'
|
||
|
|
try {
|
||
|
|
const res = await fetch('/api/blobs', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'X-Device-Token': getToken(), 'X-Blob-Kind': 'clip', 'Content-Type': 'text/plain; charset=utf-8' },
|
||
|
|
body: text,
|
||
|
|
})
|
||
|
|
if (!res.ok) throw new Error(await res.text())
|
||
|
|
result.textContent = 'Sent.'
|
||
|
|
} catch (err) {
|
||
|
|
result.textContent = `Failed: ${err.message || err}`
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// --- pull clip ---
|
||
|
|
let lastPulled = ''
|
||
|
|
$('pullClip').addEventListener('click', async () => {
|
||
|
|
const result = $('pullResult')
|
||
|
|
result.textContent = 'Fetching…'
|
||
|
|
try {
|
||
|
|
const res = await fetch('/api/clip/latest', { headers: { 'X-Device-Token': getToken() } })
|
||
|
|
if (res.status === 204) {
|
||
|
|
result.textContent = 'No clip yet.'
|
||
|
|
$('copyPulled').disabled = true
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (!res.ok) throw new Error(await res.text())
|
||
|
|
lastPulled = await res.text()
|
||
|
|
result.textContent = lastPulled
|
||
|
|
$('copyPulled').disabled = false
|
||
|
|
} catch (err) {
|
||
|
|
result.textContent = `Failed: ${err.message || err}`
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
$('copyPulled').addEventListener('click', async () => {
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(lastPulled)
|
||
|
|
toast('Copied.')
|
||
|
|
} catch {
|
||
|
|
toast('Clipboard write not available.')
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// --- push shot ---
|
||
|
|
$('pushShot').addEventListener('click', async () => {
|
||
|
|
const file = $('shotFile').files[0]
|
||
|
|
const result = $('shotResult')
|
||
|
|
if (!file) {
|
||
|
|
result.textContent = 'Pick an image first.'
|
||
|
|
return
|
||
|
|
}
|
||
|
|
result.textContent = 'Uploading…'
|
||
|
|
try {
|
||
|
|
const res = await fetch('/api/blobs', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'X-Device-Token': getToken(), 'X-Blob-Kind': 'shot', 'Content-Type': file.type || 'application/octet-stream' },
|
||
|
|
body: file,
|
||
|
|
})
|
||
|
|
if (!res.ok) throw new Error(await res.text())
|
||
|
|
const { url } = await res.json()
|
||
|
|
const fullUrl = new URL(url, location.origin).toString()
|
||
|
|
result.innerHTML = `<a href="${fullUrl}" target="_blank" rel="noopener">${fullUrl}</a>`
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(fullUrl)
|
||
|
|
toast('Link copied to clipboard.')
|
||
|
|
} catch { /* clipboard write may be unavailable; link is still shown above */ }
|
||
|
|
} catch (err) {
|
||
|
|
result.textContent = `Failed: ${err.message || err}`
|
||
|
|
}
|
||
|
|
})
|