import {
LitElement,
html,
css,
type CSSResultGroup,
type TemplateResult,
type PropertyValues,
} from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
wrapCss,
IS_APP,
VERSION,
clickOnSpacebarPress,
updateFaviconLinks,
} from "./misc";
import brandLockupColor from "~assets/brand/replaywebpage-lockup-color.svg";
import rwpLogo from "~assets/brand/replaywebpage-icon-color.svg";
import fasHelp from "@fortawesome/fontawesome-free/svgs/solid/question-circle.svg";
import fasArrowLeft from "@fortawesome/fontawesome-free/svgs/solid/arrow-left.svg";
import fasArrowRight from "@fortawesome/fontawesome-free/svgs/solid/arrow-right.svg";
import { SWManager } from "./swmanager";
import "./item";
import "./item-index";
import "./chooser";
import {
type Item,
type EmbedOpts,
type LoadInfo,
RWP_SCHEME,
EmbedReplayDataView,
} from "./item";
import type { FavIconEventDetail } from "./types";
// ===========================================================================
@customElement("replay-app-main")
export class ReplayWebApp extends LitElement {
@property({ type: Boolean })
inited = false;
@property({ type: Object })
pageParams: URLSearchParams = new URLSearchParams();
@property({ type: String })
sourceUrl: string | null = null;
@property({ type: Boolean })
navMenuShown = false;
@property({ type: Boolean })
showAbout = false;
@property({ type: Boolean })
showAllPages = false;
@property({ type: Boolean })
showFileDropOverlay = false;
@property({ type: String })
collTitle: string | null = null;
@property({ type: Object })
loadInfo: LoadInfo | null = null;
@property({ type: String })
embed: string | null = null;
@property({ type: Object })
embedOpts: EmbedOpts = {};
@property({ type: String })
collPageUrl = "";
@property({ type: String })
pageTitle = "";
@property({ type: Boolean })
pageReplay = false;
@property({ type: String })
source: string | null = null;
@property({ type: Boolean })
skipRuffle = false;
@property({ type: String })
swErrorMsg: TemplateResult<1> | string | null = null;
@query("wr-item")
private readonly item?: Item | null;
/**
* Navigate to a view in replay.
*
* @param {string} urlOrView - The page URL or view to navigate to.
* @param {Object|undefined} data - Optional view data.
*/
public navigateReplayTo(
urlOrView: EmbedReplayDataView | string,
data?: { ts: string },
) {
if (!this.item) {
console.debug("missing `this.item`");
return;
}
let value = urlOrView;
if (
Object.values(EmbedReplayDataView).some(
(view) => (view as string) === urlOrView,
)
) {
value = `${RWP_SCHEME}view=${value}${
data ? `?${new URLSearchParams(data).toString()}` : ""
}`;
}
this.item.navigateTo(value, data);
}
swName?: string;
protected swmanager: SWManager | null;
private useRuffle = false;
private droppedFile: File | null = null;
constructor(swName = __SW_NAME__) {
super();
this.swName = swName;
this.swmanager = null;
this.skipRuffle = false;
this.useRuffle = false;
this.safariKeyframes();
this.addEventListener("dragenter", (dragEvent) => {
this.maybeStartFileDrop(dragEvent);
});
this.addEventListener("dragover", (dragEvent) => {
this.maybeStartFileDrop(dragEvent);
});
this.addEventListener("dragleave", () => {
this.showFileDropOverlay = false;
});
this.addEventListener("dragend", () => {
this.showFileDropOverlay = false;
});
this.addEventListener("drop", (dragEvent) => {
this.droppedFile = dragEvent.dataTransfer?.files[0] || null;
this.showFileDropOverlay = false;
dragEvent.preventDefault();
});
}
private readonly maybeStartFileDrop = (dragEvent: DragEvent) => {
if (this.sourceUrl) {
// A source is already loaded. Don't allow dropping a file.
return;
}
this.showFileDropOverlay = true;
dragEvent.preventDefault();
};
get appName() {
return "ReplayWeb.page";
}
get homeUrl() {
return window.location.pathname;
}
static get styles() {
return wrapCss(ReplayWebApp.appStyles);
}
static get appStyles(): CSSResultGroup {
return css`
#wrlogo {
max-height: 1rem;
}
.navbar {
height: 1.5rem;
}
.navbar-brand {
height: 1.5rem;
display: flex;
align-items: center;
}
.wr-logo-item {
padding: 0 0.5rem 0 0;
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
}
.no-wrap {
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
}
.has-allcaps {
font-variant-caps: small-caps;
}
:host {
position: fixed;
left: 0px;
top: 0px;
bottom: 0px;
right: 0px;
display: flex;
min-width: 0px;
flex-direction: column;
}
wr-item {
flex: 1 1 auto;
overflow: hidden;
}
.navbar {
padding: 0 0.5em;
}
div.navbar-menu fa-icon {
vertical-align: sub;
}
.tagline {
margin-top: 1rem;
}
.drop-file-overlay {
position: relative;
display: flex;
align-items: center;
justify-content: center;
position: fixed;
inset: 0;
z-index: 50;
font-weight: bold;
font-size: 1.5rem;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(2px);
}
.drop-file-overlay:after {
pointer-events: none;
content: " ";
position: absolute;
inset: 0;
border: 5px dashed #aaa;
margin: 15px;
}
.about-dialog-header {
display: flex;
}
.about-dialog h3,
h4 {
font-weight: var(--sl-font-weight-bold);
margin-bottom: var(--sl-spacing-x-small);
}
.about-dialog p + p {
margin-top: 1em;
}
.about-dialog details p {
font-size: var(--sl-font-size-x-small);
}
@media screen and (min-width: 840px) {
.menu-only {
display: none;
}
a.arrow-button {
padding-left: 4px;
padding-right: 4px;
}
.info-menu {
padding: 0 1em;
}
.logo-text {
padding-left: 0px;
margin-left: 6px;
}
a.navbar-item.logo-text:hover {
background-color: initial;
}
}
@media screen and (max-width: 840px) {
.wide-only {
display: none !important;
}
}
`;
}
get mainLogo() {
return rwpLogo as string;
}
renderNavBrand() {
return html` `;
}
renderNavBar() {
return html` Skip main navigation
${!this.sourceUrl
? html` `
: html``}
Skipped
`;
}
renderNavEnd() {
return html`
User Docs
{
e.preventDefault();
this.showAbout = true;
}}"
class="navbar-item is-size-6"
>About
`;
}
renderColl() {
return html` (this.showAbout = true)}
exportparts="
replay-bar:wr-item__replay-bar,
replay-bar-container:wr-item__replay-bar-container,
replay-bar-input:wr-item__replay-bar-input,
replay-content:wr-item__replay-content,
replay-tabs-nav:wr-item__replay-tabs-nav,
replay-tabs-panel:wr-item__replay-tabs-panel,
replay-main:wr-item__replay-main,
loader:wr-item__loader,
"
> `;
}
renderHomeIndex() {
return html`
${!IS_APP
? html`
Explore and Replay Interactive Archived Webpages Directly in your
Browser.
(Learn More)
`
: ""}
(this.droppedFile = null)}"
@load-start=${this.onStartLoad}
>
`;
}
render() {
if (!this.inited) {
return html``;
}
if (this.embed && this.swErrorMsg) {
return this.swErrorMsg;
}
return html`
${!this.embed || this.embed === "full" ? this.renderNavBar() : ""}
${this.sourceUrl ? this.renderColl() : this.renderHomeIndex()}
${this.renderAbout()}
${this.showFileDropOverlay ? this.renderDropFileOverlay() : ""}
`;
}
firstUpdated() {
this.initRoute();
// service worker name
let name = this.swName;
const qp = new URLSearchParams();
let query = "";
if (this.useRuffle) {
qp.set("injectScripts", "ruffle/ruffle.js");
qp.set("allowProxyPaths", "ruffle/");
}
if (this.embed) {
qp.set("serveIndex", "1");
}
query = qp.toString();
if (query.length) {
name += "?" + query;
}
this.swmanager = new SWManager({ name, appName: this.appName });
this.swmanager.register().catch(
() =>
// @ts-expect-error - TS2554 - Expected 2 arguments, but got 1.
(this.swErrorMsg = this.swmanager?.renderErrorReport()),
);
window.addEventListener("popstate", () => {
this.initRoute();
});
}
updated(changedProperties: PropertyValues) {
if (changedProperties.has("sourceUrl")) {
this.collTitle = null;
}
}
onFavIcons(event: CustomEvent) {
updateFaviconLinks(event.detail);
}
// @ts-expect-error [// TODO: Fix this the next time the file is edited.] - TS7006 - Parameter 'event' implicitly has an 'any' type.
skipMenu(event) {
// This is a workaround, since this app's routing doesn't permit normal
// following of in-page anchors.
event.preventDefault();
this.renderRoot.querySelector("#skip-main-target")?.focus();
}
// @ts-expect-error [// TODO: Fix this the next time the file is edited.] - TS7006 - Parameter 'event' implicitly has an 'any' type.
onNavMenu(event) {
event.preventDefault();
event.stopPropagation();
this.navMenuShown = !this.navMenuShown;
if (this.navMenuShown) {
// Since this menu can be large and obscure significant page content,
// dismiss like a modal, returning keyboard focus to the
// menu button on dismissal.
document.addEventListener(
"click",
(event) => {
event.preventDefault();
this.navMenuShown = false;
this.renderRoot.querySelector("#menu-button")?.focus();
},
{ once: true },
);
document.addEventListener(
"keypress",
(event) => {
if (event.key == "Escape") {
event.preventDefault();
this.navMenuShown = false;
this.renderRoot.querySelector("#menu-button")?.focus();
}
},
{ once: true },
);
}
}
initRoute() {
this.inited = true;
this.pageParams = new URLSearchParams(window.location.search);
// Google Drive
const state = this.pageParams.get("state");
type State = {
ids?: string[];
userId?: string;
action?: string;
};
if (state) {
try {
const parsedState: State = JSON.parse(state);
if (
parsedState.ids instanceof Array &&
parsedState.userId &&
parsedState.action === "open"
) {
this.pageParams.set("source", "googledrive://" + parsedState.ids[0]);
this.pageParams.delete("state");
window.location.search = this.pageParams.toString();
return;
}
} catch (e) {
console.log(e);
}
}
if (this.source) {
this.pageParams.set("source", this.source);
const url = new URL(window.location.href);
url.search = this.pageParams.toString();
window.history.replaceState({}, document.title, url.href);
}
this.sourceUrl = this.pageParams.get("source") || "";
this.embed = this.pageParams.get("embed") || "";
if (!this.embed) {
this.useRuffle = !this.skipRuffle;
} else {
this.useRuffle = this.pageParams.get("ruffle") === "1";
}
if (this.pageParams.has("terms")) {
this.showAbout = true;
}
if (this.pageParams.has("embed")) {
if (!this.loadInfo) {
this.loadInfo = {};
}
if (this.pageParams.has("noMediaDownload")) {
this.embedOpts = { ...this.embedOpts, noMediaDownloadUI: true };
}
if (this.pageParams.has("hideMetadataSidebar")) {
this.embedOpts = { ...this.embedOpts, hideMetadataSidebar: true };
}
}
if (this.pageParams.get("config")) {
try {
this.loadInfo!.extraConfig = JSON.parse(
this.pageParams.get("config") || "",
);
} catch (e) {
console.log("invalid config: " + e);
}
}
if (this.pageParams.get("baseUrlSourcePrefix")) {
this.loadInfo!.extraConfig = this.loadInfo!.extraConfig || {};
this.loadInfo!.extraConfig.baseUrlSourcePrefix = this.pageParams.get(
"baseUrlSourcePrefix",
);
}
if (this.pageParams.get("basePageUrl")) {
this.loadInfo!.extraConfig = this.loadInfo!.extraConfig || {};
this.loadInfo!.extraConfig.baseUrl = this.pageParams.get("basePageUrl");
}
if (this.pageParams.get("adblockUrl")) {
this.loadInfo!.extraConfig = this.loadInfo!.extraConfig || {};
this.loadInfo!.extraConfig.adblockUrl = this.pageParams.get("adblockUrl");
}
if (this.pageParams.get("customColl")) {
this.loadInfo!.customColl = this.pageParams.get("customColl");
}
if (this.pageParams.get("noWebWorker") === "1") {
this.loadInfo!.noWebWorker = true;
}
if (this.pageParams.get("noCache") === "1") {
this.loadInfo!.noCache = true;
}
if (this.pageParams.get("hideOffscreen") === "1") {
this.loadInfo!.hideOffscreen = true;
}
if (this.pageParams.get("loading") === "eager") {
this.loadInfo!.loadEager = true;
}
if (this.pageParams.get("swName")) {
this.swName = this.pageParams.get("swName") || undefined;
}
}
// @ts-expect-error [// TODO: Fix this the next time the file is edited.] - TS7006 - Parameter 'event' implicitly has an 'any' type.
onStartLoad(event) {
// just redirect right away?
// TODO: Fix this the next time the file is edited.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.pageParams.set("source", event.detail.sourceUrl);
const url = new URL(window.location.href);
url.search = this.pageParams.toString();
this.collPageUrl = url.toString();
if (!event.detail.isFile) {
window.location.search = this.pageParams.toString();
return;
} else {
window.history.pushState({}, "", this.collPageUrl);
}
this.sourceUrl = event.detail.sourceUrl;
this.loadInfo = event.detail;
}
// @ts-expect-error [// TODO: Fix this the next time the file is edited.] - TS7006 - Parameter 'event' implicitly has an 'any' type.
onCollLoaded(event) {
this.loadInfo = null;
if (event.detail.collInfo) {
this.collTitle =
event.detail.collInfo.name || event.detail.collInfo.title;
}
if (event.detail.alreadyLoaded) {
//this.sourceLoaded = true;
return;
}
//this.initRoute();
if (event.detail.sourceUrl !== this.sourceUrl) {
// TODO: Fix this the next time the file is edited.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.pageParams.set("source", event.detail.sourceUrl);
window.location.search = this.pageParams.toString();
}
}
private onCollLoadCancel() {
this.sourceUrl = null;
this.loadInfo = null;
window.history.pushState({}, "", new URL(window.location.href).origin);
}
// @ts-expect-error [// TODO: Fix this the next time the file is edited.] - TS7006 - Parameter 'event' implicitly has an 'any' type.
onTitle(event) {
if (event.detail.title) {
this.pageTitle = event.detail.title;
this.pageReplay = event.detail.replayTitle;
document.title =
(event.detail.replayTitle ? "Archive of " : "") +
this.pageTitle +
" | " +
this.appName;
}
}
safariKeyframes() {
// Safari requires keyframes at the document level, add spinner keyframes here
const css = `
@keyframes spinAround {
from {
transform: rotate(0deg);
}
to {
transform: rotate(359deg);
}
}
`;
const style = document.createElement("style");
document.head.appendChild(style);
style.appendChild(document.createTextNode(css));
}
renderAbout() {
return html`
Full source code is available
on GitHub .
See the documentation for more info
on how it works.
ReplayWeb.page is developed by
Webrecorder .
Privacy
No data is uploaded anywhere and no information is collected.
More info
All content rendered stays directly in your browser. When loading
an archive from Google Drive, the site may ask for account
authorization to download the specified file only.
Disclaimer of Warranties
The application is provided "as is" without any guarantees.
Legal
DISCLAIMER OF SOFTWARE WARRANTY. WEBRECORDER SOFTWARE PROVIDES THIS
SOFTWARE TO YOU "AS AVAILABLE" AND WITHOUT WARRANTY OF ANY KIND,
EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION ANY
WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
Close
`;
}
readonly onAboutClose = () => {
this.showAbout = false;
};
renderDropFileOverlay() {
return html`
Drop to load web archive
`;
}
}