coldwire-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/VERSION +1 -0
- data/app/assets/javascripts/coldwire/archives.js +52 -0
- data/app/assets/javascripts/coldwire/cache_controller.js +1058 -0
- data/app/assets/javascripts/coldwire/entries.js +55 -0
- data/app/assets/javascripts/coldwire/format.js +60 -0
- data/app/assets/javascripts/coldwire/worker.js +35 -0
- data/app/controllers/coldwire/application_controller.rb +21 -0
- data/app/controllers/coldwire/caches_controller.rb +33 -0
- data/app/controllers/coldwire/service_worker_controller.rb +26 -0
- data/app/helpers/coldwire/service_worker_helper.rb +59 -0
- data/app/views/coldwire/caches/show.html.erb +304 -0
- data/app/views/coldwire/service_worker/offline_frame.html.erb +16 -0
- data/app/views/coldwire/service_worker/offline_page.html.erb +112 -0
- data/app/views/coldwire/service_worker/show.js.erb +66 -0
- data/config/importmap.rb +7 -0
- data/config/routes.rb +8 -0
- data/docs/README.md +19 -0
- data/docs/configuration.md +539 -0
- data/docs/how-it-works.md +64 -0
- data/docs/images/offline-fallback.png +0 -0
- data/docs/images/offline-settings-cached.png +0 -0
- data/docs/images/offline-settings.png +0 -0
- data/docs/setup.md +218 -0
- data/lib/coldwire/client/api.js +44 -0
- data/lib/coldwire/client/cookie.js +13 -0
- data/lib/coldwire/client/forced.js +17 -0
- data/lib/coldwire/client/identity.js +28 -0
- data/lib/coldwire/client/marker.js +35 -0
- data/lib/coldwire/client/register.js +19 -0
- data/lib/coldwire/client/store.js +69 -0
- data/lib/coldwire/client/sync.js +122 -0
- data/lib/coldwire/client_user_agent.rb +48 -0
- data/lib/coldwire/configuration.rb +319 -0
- data/lib/coldwire/debug.css +197 -0
- data/lib/coldwire/engine.rb +34 -0
- data/lib/coldwire/source.rb +50 -0
- data/lib/coldwire/version.rb +11 -0
- data/lib/coldwire/worker/archives.js +158 -0
- data/lib/coldwire/worker/events.js +79 -0
- data/lib/coldwire/worker/inspect.js +81 -0
- data/lib/coldwire/worker/ranges.js +136 -0
- data/lib/coldwire/worker/rules.js +100 -0
- data/lib/coldwire/worker/serve.js +226 -0
- data/lib/coldwire/worker/sync.js +234 -0
- data/lib/coldwire-rails.rb +5 -0
- data/lib/coldwire.rb +55 -0
- data/lib/generators/coldwire/install/install_generator.rb +110 -0
- data/lib/generators/coldwire/install/templates/coldwire.rb +53 -0
- data/lib/tasks/coldwire.rake +8 -0
- metadata +113 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Wording for the cached list and for a finished sync.
|
|
2
|
+
|
|
3
|
+
import { formatBytes, formatCachedAt } from "coldwire/format"
|
|
4
|
+
|
|
5
|
+
// The worker stamps this on the request it stores under; the Cache API keeps no date
|
|
6
|
+
// of its own.
|
|
7
|
+
export const TIMESTAMP_HEADER = "timestamp"
|
|
8
|
+
|
|
9
|
+
export function describeEntry(entry) {
|
|
10
|
+
const parts = [ formatBytes(entry.size) ]
|
|
11
|
+
if (entry.timestamp) parts.push(`cached ${formatCachedAt(entry.timestamp)}`)
|
|
12
|
+
if (entry.cache) parts.push(`in “${entry.cache}”`)
|
|
13
|
+
|
|
14
|
+
return parts.join(" · ")
|
|
15
|
+
}
|
|
16
|
+
export async function describeCached(request, response) {
|
|
17
|
+
const seconds = Number(request.headers.get(TIMESTAMP_HEADER))
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
url: request.url,
|
|
21
|
+
size: await entrySize(response),
|
|
22
|
+
timestamp: Number.isFinite(seconds) && seconds > 0 ? seconds : null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function describeFinishedSync(data) {
|
|
26
|
+
// Nothing went wrong and nothing needs retrying — it was simply never asked to work.
|
|
27
|
+
if (data.offline) {
|
|
28
|
+
return data.reason === "forced"
|
|
29
|
+
? "Paused while force offline is on."
|
|
30
|
+
: "No connection. Will sync when it is back."
|
|
31
|
+
}
|
|
32
|
+
if (data.error) return `Sync failed: ${data.error}`
|
|
33
|
+
|
|
34
|
+
const parts = [ `Cached ${data.cached}` ]
|
|
35
|
+
if (data.retired) parts.push(`retired ${data.retired}`)
|
|
36
|
+
if (data.failed?.length) parts.push(`${data.failed.length} failed`)
|
|
37
|
+
// A sync attempts the whole manifest, so anything left is something that would not
|
|
38
|
+
// fetch. It stays missing, so the next sync finds it and tries again.
|
|
39
|
+
if (!data.complete) parts.push(`${data.remaining} to retry`)
|
|
40
|
+
|
|
41
|
+
return `${parts.join(", ")}.`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Headers before body: blob() on hundreds of entries makes listing the cache a multi-second
|
|
45
|
+
// job. Note that `get` answers null for a missing header and Number(null) is 0, which sailed
|
|
46
|
+
// through the guard and reported Active Storage's streamed blobs as empty.
|
|
47
|
+
async function entrySize(response) {
|
|
48
|
+
if (!response) return 0
|
|
49
|
+
|
|
50
|
+
const declared = response.headers.get("Content-Length")
|
|
51
|
+
const bytes = declared === null ? NaN : Number(declared)
|
|
52
|
+
if (Number.isFinite(bytes) && bytes >= 0) return bytes
|
|
53
|
+
|
|
54
|
+
return (await response.clone().blob()).size
|
|
55
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Turning numbers and timestamps into the words the offline settings page shows.
|
|
2
|
+
|
|
3
|
+
// Whole words. "every 1 d" reads like a typo, and the cadence is the one number on this
|
|
4
|
+
// card somebody is meant to act on.
|
|
5
|
+
export function formatDuration(seconds) {
|
|
6
|
+
if (seconds < 60) return `${seconds} sec`
|
|
7
|
+
if (seconds < 3600) return plural(Math.round(seconds / 60), "min", "min")
|
|
8
|
+
if (seconds < 86400) return plural(Math.round(seconds / 3600), "hour")
|
|
9
|
+
|
|
10
|
+
return plural(Math.round(seconds / 86400), "day")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function plural(count, one, many = `${one}s`) {
|
|
14
|
+
return `${count} ${count === 1 ? one : many}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// "day", not "1 day" — the caller writes "Syncs every day".
|
|
18
|
+
export function formatInterval(seconds) {
|
|
19
|
+
const text = formatDuration(seconds)
|
|
20
|
+
|
|
21
|
+
return text.startsWith("1 ") ? text.slice(2) : text
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function displayUrl(href) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(href)
|
|
27
|
+
return `${url.pathname}${url.search}`
|
|
28
|
+
} catch {
|
|
29
|
+
return href
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatBytes(bytes) {
|
|
34
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "—"
|
|
35
|
+
if (bytes < 1024) return `${bytes} B`
|
|
36
|
+
if (bytes < 1024 * 1024) {
|
|
37
|
+
const kb = bytes / 1024
|
|
38
|
+
return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`
|
|
39
|
+
}
|
|
40
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Built once. Constructing an Intl formatter is expensive, and this is called for every row
|
|
44
|
+
// on every keystroke — 485 of them cost 40ms a piece of typing when it was built per call.
|
|
45
|
+
const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
|
46
|
+
|
|
47
|
+
export function formatCachedAt(timestamp) {
|
|
48
|
+
if (!timestamp) return "Unknown time"
|
|
49
|
+
const date = new Date(timestamp * 1000)
|
|
50
|
+
if (Number.isNaN(date.getTime())) return "Unknown time"
|
|
51
|
+
|
|
52
|
+
const deltaSec = Math.round((date.getTime() - Date.now()) / 1000)
|
|
53
|
+
const abs = Math.abs(deltaSec)
|
|
54
|
+
const rtf = relative
|
|
55
|
+
if (abs < 60) return rtf.format(deltaSec, "second")
|
|
56
|
+
if (abs < 3600) return rtf.format(Math.round(deltaSec / 60), "minute")
|
|
57
|
+
if (abs < 86400) return rtf.format(Math.round(deltaSec / 3600), "hour")
|
|
58
|
+
if (abs < 86400 * 7) return rtf.format(Math.round(deltaSec / 86400), "day")
|
|
59
|
+
return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Talking to the service worker: one request, one reply, on a port this page owns.
|
|
2
|
+
|
|
3
|
+
export async function sendToWorker(type, payload, timeoutMs) {
|
|
4
|
+
if (!("serviceWorker" in navigator)) {
|
|
5
|
+
throw new Error("Service workers are not available in this web view")
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// `ready` is a promise for a registration that may never exist, not a check — with nothing
|
|
9
|
+
// registered for this scope it simply never settles. Unbounded, it hangs whatever awaited
|
|
10
|
+
// it: the offline settings page sat on "Checking…" with an empty list, because the refresh never got
|
|
11
|
+
// past its first question to the worker.
|
|
12
|
+
const registration = await deadline(
|
|
13
|
+
navigator.serviceWorker.ready, timeoutMs, "No service worker is registered for this page"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if (!registration.active) {
|
|
17
|
+
throw new Error("Service worker is not active yet. Reload and try again.")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return deadline(new Promise((resolve) => {
|
|
21
|
+
const { port1, port2 } = new MessageChannel()
|
|
22
|
+
port1.onmessage = (event) => resolve(event.data)
|
|
23
|
+
registration.active.postMessage({ type, ...payload }, [ port2 ])
|
|
24
|
+
}), timeoutMs, "Timed out")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A worker torn down mid-job answers nobody, so every wait on one needs a way out.
|
|
28
|
+
function deadline(promise, timeoutMs, message) {
|
|
29
|
+
let timer = null
|
|
30
|
+
|
|
31
|
+
return Promise.race([
|
|
32
|
+
promise.finally(() => window.clearTimeout(timer)),
|
|
33
|
+
new Promise((_, reject) => { timer = window.setTimeout(() => reject(new Error(message)), timeoutMs) })
|
|
34
|
+
])
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coldwire
|
|
4
|
+
# Inherits the host's ApplicationController so the offline settings page picks up its layout,
|
|
5
|
+
# authentication, and helpers.
|
|
6
|
+
class ApplicationController < ::ApplicationController
|
|
7
|
+
# The engine is isolated, so bare route helpers inside it resolve against the engine's
|
|
8
|
+
# own routes. The host's layout calls its own helpers (`root_path`, `sites_path`, …),
|
|
9
|
+
# which would otherwise raise. Coldwire's own views still say `coldwire.pack_path`.
|
|
10
|
+
helper ::Rails.application.routes.url_helpers
|
|
11
|
+
|
|
12
|
+
# Those helpers still build on the request's SCRIPT_NAME, which inside a mounted engine is
|
|
13
|
+
# the mount point — so a host layout asking for `sites_path` got "/offline/sites", a URL
|
|
14
|
+
# the app does not serve. Clear it: the host's helpers describe the host's routes, which
|
|
15
|
+
# begin at the root whatever Coldwire is mounted under. Coldwire's own views say
|
|
16
|
+
# `coldwire.pack_path`, and that proxy supplies the mount itself.
|
|
17
|
+
def url_options
|
|
18
|
+
super.merge(script_name: "")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coldwire
|
|
4
|
+
# Offline settings: inspect what is cached, precache the manifest, force offline.
|
|
5
|
+
class CachesController < Coldwire::ApplicationController
|
|
6
|
+
def show
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# The precache manifest. Never stored by anything: the worker asks with `cache: "no-store"`,
|
|
10
|
+
# but that binds only the one caller, and a browser serving a heuristically-fresh copy had
|
|
11
|
+
# syncs faithfully fetching yesterday's list.
|
|
12
|
+
def pack
|
|
13
|
+
response.headers["Cache-Control"] = "no-store, private"
|
|
14
|
+
|
|
15
|
+
render json: { urls: Array(precache_urls) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Evaluated against the host's URL helpers, so `site_path(site)` means the host's route
|
|
21
|
+
# rather than one of Coldwire's. A lambda that takes an argument is handed this controller.
|
|
22
|
+
def precache_urls
|
|
23
|
+
manifest = Coldwire.config.auto_sync.precache_urls
|
|
24
|
+
helpers = ::Rails.application.routes.url_helpers
|
|
25
|
+
|
|
26
|
+
if manifest.arity.zero?
|
|
27
|
+
helpers.instance_exec(&manifest)
|
|
28
|
+
else
|
|
29
|
+
helpers.instance_exec(self, &manifest)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coldwire
|
|
4
|
+
# Serves the worker script. Deliberately not the host's ApplicationController: the worker
|
|
5
|
+
# must be fetchable before anyone signs in, and an authentication filter would break
|
|
6
|
+
# registration.
|
|
7
|
+
class ServiceWorkerController < ActionController::Base
|
|
8
|
+
# The worker carries no user data and is fetched by the browser's worker loader, not an
|
|
9
|
+
# XHR, so Rails' cross-origin script guard only rejects legitimate registrations.
|
|
10
|
+
skip_forgery_protection
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
# A worker's scope is capped by the directory it is served from, so an engine mounted at
|
|
14
|
+
# /offline would only control /offline/*. This lifts the cap.
|
|
15
|
+
response.headers["Service-Worker-Allowed"] = Coldwire.config.worker_scope
|
|
16
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
17
|
+
|
|
18
|
+
render(
|
|
19
|
+
template: "coldwire/service_worker/show",
|
|
20
|
+
formats: :js,
|
|
21
|
+
layout: false,
|
|
22
|
+
content_type: "text/javascript"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coldwire
|
|
4
|
+
# Emits everything Coldwire needs in a page's <head>: a meta carrying the sync interval, and
|
|
5
|
+
# one script that registers the worker and keeps the few things a worker cannot remember.
|
|
6
|
+
#
|
|
7
|
+
# The script itself lives in lib/coldwire/client as ordinary JavaScript. Only the values it
|
|
8
|
+
# cannot know are passed in, as a `COLDWIRE` object it reads.
|
|
9
|
+
module ServiceWorkerHelper
|
|
10
|
+
USER_AGENT_COOKIE = "coldwire-user-agent"
|
|
11
|
+
|
|
12
|
+
def coldwire_service_worker_tag
|
|
13
|
+
return unless Coldwire.config.register?(self)
|
|
14
|
+
|
|
15
|
+
safe_join([ coldwire_sync_interval_meta, coldwire_client_tag ].compact, "\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def coldwire_debug_styles
|
|
19
|
+
tag.style(Coldwire::Source.debug_css.html_safe)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def coldwire_client_tag
|
|
25
|
+
javascript_tag(nonce: true) do
|
|
26
|
+
Coldwire::Source.client(coldwire_client_config, coldwire_client_parts).html_safe
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def coldwire_client_config
|
|
31
|
+
{
|
|
32
|
+
cacheName: Coldwire.config.cache_name,
|
|
33
|
+
identity: Coldwire.config.cache_identity(self),
|
|
34
|
+
workerPath: coldwire.service_worker_path,
|
|
35
|
+
workerScope: Coldwire.config.worker_scope,
|
|
36
|
+
syncInterval: Coldwire.config.auto_sync.interval.to_i * 1000,
|
|
37
|
+
cachingEnabledByDefault: Coldwire.config.caching_enabled_by_default,
|
|
38
|
+
userAgentCookie: USER_AGENT_COOKIE
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Order matters: the store before anything that reads it, the registration last.
|
|
43
|
+
def coldwire_client_parts
|
|
44
|
+
parts = %w[store api identity]
|
|
45
|
+
parts << "marker" if Coldwire.config.mark_cached_pages
|
|
46
|
+
parts += %w[cookie forced]
|
|
47
|
+
parts << "sync" if Coldwire.config.auto_sync.enabled
|
|
48
|
+
parts << "register"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Read on every use rather than baked into the script, so a document that outlives a config
|
|
52
|
+
# change follows the new interval rather than the one it was born with.
|
|
53
|
+
def coldwire_sync_interval_meta
|
|
54
|
+
return unless Coldwire.config.auto_sync.enabled
|
|
55
|
+
|
|
56
|
+
tag.meta(name: "coldwire-sync-interval", content: Coldwire.config.auto_sync.interval.to_i)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
<%# Heroicons trash, outline. Held in one place: it is drawn both by Clear cache and by every
|
|
2
|
+
row in the cached list, and a path this long is not worth keeping in step by hand. %>
|
|
3
|
+
<% trash_path = "m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" %>
|
|
4
|
+
|
|
5
|
+
<%# Coldwire's offline settings page. Styled with plain CSS on purpose: the gem must not assume
|
|
6
|
+
Tailwind, Bootstrap, or any host framework. Override this template by creating
|
|
7
|
+
app/views/coldwire/caches/show.html.erb in your own app. The title goes through
|
|
8
|
+
`content_for :title` so the host layout can put it in `<title>` (and Hotwire Native's
|
|
9
|
+
bar) rather than repeating it as a heading on the page. %>
|
|
10
|
+
|
|
11
|
+
<% content_for :title, "Offline settings" %>
|
|
12
|
+
|
|
13
|
+
<%= coldwire_debug_styles %>
|
|
14
|
+
|
|
15
|
+
<% unless Coldwire.config.register?(self) %>
|
|
16
|
+
<%# `register_if` said no for this request, so no worker was ever registered here: nothing
|
|
17
|
+
is cached, nothing syncs, and force offline has nothing to force. Showing an empty cache
|
|
18
|
+
and a countdown would read as a broken page rather than a switched-off one. %>
|
|
19
|
+
<div class="coldwire">
|
|
20
|
+
<div class="coldwire-card">
|
|
21
|
+
<p class="coldwire-headline">
|
|
22
|
+
<span class="coldwire-light" data-state="disabled" aria-hidden="true"></span>
|
|
23
|
+
Offline is off for this device
|
|
24
|
+
</p>
|
|
25
|
+
<p class="coldwire-facts">
|
|
26
|
+
Coldwire is not running here, so nothing is being cached and nothing will be available
|
|
27
|
+
without a connection. Whether it runs is decided per request by
|
|
28
|
+
<code>config.register_if</code> — typically the native app, signed in.
|
|
29
|
+
</p>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<% else %>
|
|
33
|
+
<div class="coldwire"
|
|
34
|
+
data-controller="coldwire-cache"
|
|
35
|
+
data-coldwire-cache-probe-url-value="<%= Coldwire.config.probe_path %>"
|
|
36
|
+
data-coldwire-cache-auto-sync-value="<%= Coldwire.config.auto_sync.enabled %>"
|
|
37
|
+
data-coldwire-cache-sync-interval-value="<%= Coldwire.config.auto_sync.interval.to_i %>">
|
|
38
|
+
<div class="coldwire-card">
|
|
39
|
+
<div class="coldwire-head" data-coldwire-cache-target="whenOn">
|
|
40
|
+
<div>
|
|
41
|
+
<p class="coldwire-headline">
|
|
42
|
+
<span class="coldwire-light" data-coldwire-cache-target="connectionLight" aria-hidden="true"></span>
|
|
43
|
+
<span data-coldwire-cache-target="connection">Checking…</span>
|
|
44
|
+
</p>
|
|
45
|
+
<p class="coldwire-facts">
|
|
46
|
+
<span data-coldwire-cache-target="total">Reading the cache…</span>
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="coldwire-actions">
|
|
51
|
+
<button type="button"
|
|
52
|
+
class="coldwire-button coldwire-icon-button"
|
|
53
|
+
title="Reload this page"
|
|
54
|
+
aria-label="Reload this page"
|
|
55
|
+
data-coldwire-cache-target="refreshButton"
|
|
56
|
+
data-action="click->coldwire-cache#reload">
|
|
57
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
58
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
|
59
|
+
</svg>
|
|
60
|
+
</button>
|
|
61
|
+
<button type="button"
|
|
62
|
+
class="coldwire-button coldwire-icon-button coldwire-button--danger"
|
|
63
|
+
title="Delete everything cached"
|
|
64
|
+
aria-label="Delete everything cached"
|
|
65
|
+
data-coldwire-cache-target="clearButton"
|
|
66
|
+
data-action="click->coldwire-cache#clear">
|
|
67
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
68
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="<%= trash_path %>" />
|
|
69
|
+
</svg>
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<label class="coldwire-setting">
|
|
75
|
+
<span class="coldwire-switch coldwire-switch--on">
|
|
76
|
+
<input type="checkbox"
|
|
77
|
+
data-coldwire-cache-target="cachingToggle"
|
|
78
|
+
data-action="change->coldwire-cache#toggleCaching">
|
|
79
|
+
<span aria-hidden="true"></span>
|
|
80
|
+
</span>
|
|
81
|
+
<span>
|
|
82
|
+
<span class="coldwire-setting-title">Offline support</span>
|
|
83
|
+
<span class="coldwire-setting-note">
|
|
84
|
+
Cache pages so they work without a connection.
|
|
85
|
+
</span>
|
|
86
|
+
</span>
|
|
87
|
+
</label>
|
|
88
|
+
|
|
89
|
+
<div data-coldwire-cache-target="whenOn">
|
|
90
|
+
<hr>
|
|
91
|
+
<label class="coldwire-setting">
|
|
92
|
+
<span class="coldwire-switch">
|
|
93
|
+
<input type="checkbox"
|
|
94
|
+
data-coldwire-cache-target="forcedToggle"
|
|
95
|
+
data-action="change->coldwire-cache#toggleForced">
|
|
96
|
+
<span aria-hidden="true"></span>
|
|
97
|
+
</span>
|
|
98
|
+
<span>
|
|
99
|
+
<span class="coldwire-setting-title">Force offline</span>
|
|
100
|
+
<span class="coldwire-setting-note">
|
|
101
|
+
Turn this on if you have a really slow connection.
|
|
102
|
+
</span>
|
|
103
|
+
</span>
|
|
104
|
+
</label>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div data-coldwire-cache-target="whenOn">
|
|
109
|
+
<div class="coldwire-card">
|
|
110
|
+
<h2>Auto Sync</h2>
|
|
111
|
+
|
|
112
|
+
<% if Coldwire.config.auto_sync.enabled %>
|
|
113
|
+
<%# Only where the app configured automatic syncing at all: a switch for something that
|
|
114
|
+
was never going to happen is a switch that lies. The card leads with it and lets its
|
|
115
|
+
note do the explaining, the way Force offline does. %>
|
|
116
|
+
<label class="coldwire-setting">
|
|
117
|
+
<span class="coldwire-switch coldwire-switch--on">
|
|
118
|
+
<input type="checkbox"
|
|
119
|
+
data-coldwire-cache-target="autoSyncToggle"
|
|
120
|
+
data-action="change->coldwire-cache#toggleAutoSync">
|
|
121
|
+
<span aria-hidden="true"></span>
|
|
122
|
+
</span>
|
|
123
|
+
<span>
|
|
124
|
+
<span class="coldwire-setting-title">Sync on its own</span>
|
|
125
|
+
<span class="coldwire-setting-note">
|
|
126
|
+
Automatically runs in the background backing up pages to be used offline.
|
|
127
|
+
</span>
|
|
128
|
+
</span>
|
|
129
|
+
</label>
|
|
130
|
+
<% else %>
|
|
131
|
+
<p class="coldwire-facts" style="margin-top: 0;">
|
|
132
|
+
Automatically runs in the background backing up pages to be used offline.
|
|
133
|
+
<strong>Sync now</strong> runs that pass immediately.
|
|
134
|
+
</p>
|
|
135
|
+
<% end %>
|
|
136
|
+
|
|
137
|
+
<p class="coldwire-facts">
|
|
138
|
+
<span data-coldwire-cache-target="autoSync">Checking…</span><br>
|
|
139
|
+
<span data-coldwire-cache-target="syncedAt">Never synced</span>
|
|
140
|
+
</p>
|
|
141
|
+
|
|
142
|
+
<%# Empty until a sync has something to say, so the card does not carry a permanent
|
|
143
|
+
"Idle" that reads like debug output left in by accident. %>
|
|
144
|
+
<p class="coldwire-facts" data-coldwire-cache-target="syncStatus" hidden></p>
|
|
145
|
+
|
|
146
|
+
<div class="coldwire-actions coldwire-actions--spaced">
|
|
147
|
+
<button type="button"
|
|
148
|
+
data-coldwire-cache-target="syncButton"
|
|
149
|
+
class="coldwire-button"
|
|
150
|
+
data-action="click->coldwire-cache#syncNow">
|
|
151
|
+
<span class="coldwire-spinner" data-coldwire-cache-target="spinner" aria-hidden="true" hidden></span>
|
|
152
|
+
<span data-coldwire-cache-target="syncLabel">Sync now</span>
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div style="margin-top: 0.75rem;"
|
|
157
|
+
role="progressbar"
|
|
158
|
+
aria-valuemin="0"
|
|
159
|
+
aria-valuemax="100"
|
|
160
|
+
data-coldwire-cache-target="progress"
|
|
161
|
+
hidden>
|
|
162
|
+
<div class="coldwire-track">
|
|
163
|
+
<div class="coldwire-bar" data-coldwire-cache-target="progressBar"></div>
|
|
164
|
+
</div>
|
|
165
|
+
<div class="coldwire-progress-label" data-coldwire-cache-target="progressLabel"></div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<% if Coldwire.config.cache_archives.any? %>
|
|
170
|
+
<%# One row per configured file. The words are the app's — the page knows only that these
|
|
171
|
+
are large, optional, and worth keeping. %>
|
|
172
|
+
<div class="coldwire-card" data-coldwire-cache-target="archives">
|
|
173
|
+
<% Coldwire.config.cache_archives.each do |archive| %>
|
|
174
|
+
<div class="coldwire-archive" data-archive-url="<%= archive[:url] %>">
|
|
175
|
+
<div class="coldwire-archive-title"><%= archive[:title] %></div>
|
|
176
|
+
<% if archive[:description].present? %>
|
|
177
|
+
<p class="coldwire-facts"><%= archive[:description] %></p>
|
|
178
|
+
<% end %>
|
|
179
|
+
<div class="coldwire-actions">
|
|
180
|
+
<button type="button" class="coldwire-button"
|
|
181
|
+
data-url="<%= archive[:url] %>"
|
|
182
|
+
data-action="click->coldwire-cache#downloadArchive">
|
|
183
|
+
<span class="coldwire-spinner" data-archive-spinner aria-hidden="true" hidden></span>
|
|
184
|
+
<span data-archive-download-label>Download</span>
|
|
185
|
+
</button>
|
|
186
|
+
<button type="button" class="coldwire-button coldwire-button--danger"
|
|
187
|
+
data-url="<%= archive[:url] %>"
|
|
188
|
+
data-action="click->coldwire-cache#removeArchive"
|
|
189
|
+
data-archive-remove hidden>
|
|
190
|
+
Delete
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<p class="coldwire-facts coldwire-archive-meta" data-archive-status>Checking…</p>
|
|
195
|
+
|
|
196
|
+
<div style="margin-top: 0.75rem;" role="progressbar" aria-valuemin="0" aria-valuemax="100"
|
|
197
|
+
data-archive-progress hidden>
|
|
198
|
+
<div class="coldwire-track"><div class="coldwire-bar" data-archive-bar></div></div>
|
|
199
|
+
<div class="coldwire-progress-label" data-archive-progress-label></div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
<% end %>
|
|
203
|
+
</div>
|
|
204
|
+
<% end %>
|
|
205
|
+
|
|
206
|
+
<%# The URL list is a debug surface, not something most people need. Closed until they
|
|
207
|
+
open it; remembered after that. %>
|
|
208
|
+
<details class="coldwire-inspect"
|
|
209
|
+
data-coldwire-cache-target="inspect"
|
|
210
|
+
data-action="toggle->coldwire-cache#toggleInspect">
|
|
211
|
+
<summary>
|
|
212
|
+
<span class="coldwire-inspect-label">Inspect cache</span>
|
|
213
|
+
<span class="coldwire-inspect-hint">Every URL stored on this device</span>
|
|
214
|
+
</summary>
|
|
215
|
+
|
|
216
|
+
<div class="coldwire-inspect-body">
|
|
217
|
+
<div class="coldwire-filters">
|
|
218
|
+
<input type="search"
|
|
219
|
+
class="coldwire-input"
|
|
220
|
+
placeholder="Filter by path"
|
|
221
|
+
aria-label="Filter cached files by path"
|
|
222
|
+
autocomplete="off"
|
|
223
|
+
autocapitalize="none"
|
|
224
|
+
spellcheck="false"
|
|
225
|
+
data-coldwire-cache-target="search"
|
|
226
|
+
data-action="input->coldwire-cache#filterEntries search->coldwire-cache#filterEntries">
|
|
227
|
+
<select class="coldwire-select"
|
|
228
|
+
aria-label="Sort cached files"
|
|
229
|
+
data-coldwire-cache-target="sort"
|
|
230
|
+
data-action="change->coldwire-cache#filterEntries">
|
|
231
|
+
<option value="recent">Most recent</option>
|
|
232
|
+
<option value="largest">Largest</option>
|
|
233
|
+
<option value="alphabetical">A–Z</option>
|
|
234
|
+
</select>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<p class="coldwire-facts coldwire-count" data-coldwire-cache-target="summary">Reading the cache…</p>
|
|
238
|
+
|
|
239
|
+
<ul class="coldwire-entries" data-coldwire-cache-target="entries">
|
|
240
|
+
<li class="coldwire-empty">Nothing cached yet.</li>
|
|
241
|
+
</ul>
|
|
242
|
+
|
|
243
|
+
<%# Cloned for each row. Keeping the markup here rather than assembling it in JavaScript
|
|
244
|
+
means the icon and its wrapper are described once, in the place you would look. %>
|
|
245
|
+
<template data-coldwire-cache-target="forgetTemplate">
|
|
246
|
+
<button type="button" class="coldwire-entry-forget" data-action="click->coldwire-cache#forgetEntry">
|
|
247
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
248
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="<%= trash_path %>" />
|
|
249
|
+
</svg>
|
|
250
|
+
</button>
|
|
251
|
+
</template>
|
|
252
|
+
</div>
|
|
253
|
+
</details>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<%# A real <dialog>: Escape, focus handling and the backdrop come with it, and none of that
|
|
257
|
+
is worth reimplementing to show one string. %>
|
|
258
|
+
<dialog class="coldwire-dialog"
|
|
259
|
+
data-coldwire-cache-target="detail"
|
|
260
|
+
data-action="click->coldwire-cache#closeDetailOnBackdrop
|
|
261
|
+
keydown->coldwire-cache#closeDetailOnEscape">
|
|
262
|
+
<h2>Cached file</h2>
|
|
263
|
+
<p class="coldwire-dialog-url" data-coldwire-cache-target="detailUrl"></p>
|
|
264
|
+
<p class="coldwire-facts" data-coldwire-cache-target="detailMeta"></p>
|
|
265
|
+
<div class="coldwire-actions coldwire-actions--spaced">
|
|
266
|
+
<button type="button" class="coldwire-button" data-action="click->coldwire-cache#closeDetail">
|
|
267
|
+
Close
|
|
268
|
+
</button>
|
|
269
|
+
<button type="button"
|
|
270
|
+
class="coldwire-button coldwire-button--danger"
|
|
271
|
+
data-coldwire-cache-target="detailForget"
|
|
272
|
+
data-action="click->coldwire-cache#forgetEntry">
|
|
273
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
274
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="<%= trash_path %>" />
|
|
275
|
+
</svg>
|
|
276
|
+
Delete
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
</dialog>
|
|
280
|
+
|
|
281
|
+
<%# Turning offline support off deletes everything stored. Ask first, and keep the switch
|
|
282
|
+
on until they confirm — cancel is the safe default. %>
|
|
283
|
+
<dialog class="coldwire-dialog"
|
|
284
|
+
data-coldwire-cache-target="disableConfirm"
|
|
285
|
+
data-action="cancel->coldwire-cache#cancelDisableCaching
|
|
286
|
+
click->coldwire-cache#closeDisableOnBackdrop">
|
|
287
|
+
<h2>Turn off offline support?</h2>
|
|
288
|
+
<p class="coldwire-facts" style="margin-top: 0;">
|
|
289
|
+
Everything saved for offline use will be deleted. Pages will not be available without
|
|
290
|
+
a connection until you turn this back on.
|
|
291
|
+
</p>
|
|
292
|
+
<div class="coldwire-actions coldwire-actions--spaced">
|
|
293
|
+
<button type="button" class="coldwire-button" data-action="click->coldwire-cache#cancelDisableCaching">
|
|
294
|
+
Cancel
|
|
295
|
+
</button>
|
|
296
|
+
<button type="button"
|
|
297
|
+
class="coldwire-button coldwire-button--danger"
|
|
298
|
+
data-action="click->coldwire-cache#confirmDisableCaching">
|
|
299
|
+
Turn off and delete
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
</dialog>
|
|
303
|
+
</div>
|
|
304
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# Inner content of the offline fallback for a Turbo Frame request. The worker wraps this
|
|
2
|
+
in a <turbo-frame> carrying the requested id. Override by creating this same path.
|
|
3
|
+
|
|
4
|
+
`coldwire:retry-url` is substituted with the URL this frame was requesting. Use it rather
|
|
5
|
+
than an empty href: inside a frame that would resolve to the *page* URL and load the whole
|
|
6
|
+
document into the card. A link inside a frame targets that frame, so this retries just
|
|
7
|
+
this card. %>
|
|
8
|
+
<div style="font-family: system-ui, sans-serif; padding: 1rem; color: #4b5563;">
|
|
9
|
+
<strong style="display: block; color: #1f2937;">Not available offline</strong>
|
|
10
|
+
<span style="font-size: 0.875rem;">Reconnect to see this.</span>
|
|
11
|
+
<a href="coldwire:retry-url"
|
|
12
|
+
style="display: inline-block; margin-top: 0.5rem; font-size: 0.875rem;
|
|
13
|
+
font-weight: 600; color: #1f2937;">
|
|
14
|
+
Try again
|
|
15
|
+
</a>
|
|
16
|
+
</div>
|