1
0
mirror of https://github.com/webrecorder/pywb.git synced 2025-03-15 00:03:28 +01:00

initial pass on integrating vueui calendar + banner from @vanecat

- use rollup to build vue ui, build at vue/vueui.js
- calendar page renders via both /*/ and /*? queries
- banner support for framed mode only for now
- ensure banner updated in response to inner frame events
- combine cdxquery classes into vue build, expose single entrypoint VueUI.main

- initial set of vueui dev, calendar, banner, calendar hover mode

- ensure embedded mode replay is working

- config: rename ui vars to 'vue_calendar_ui' and 'vue_timeline_banner'

- dependencies: update to wombat 3.3.6

- bump to 2.7.0b0
This commit is contained in:
Ivan Velev 2021-08-31 21:17:01 -07:00 committed by Tessa Walsh
parent 1dedc46dce
commit 028e7102c0
28 changed files with 18679 additions and 10 deletions

View File

@ -1,6 +1,10 @@
# pywb config file
# ========================================
#
debug: true
ui:
vue_calendar_ui: true
vue_timeline_banner: true
collections:
all: $all
@ -8,6 +12,8 @@ collections:
index_paths: ./sample_archive/cdx/
archive_paths: ./sample_archive/warcs/
ukwa: cdx+https://www.webarchive.org.uk/wayback/archive/cdx
# Settings for each collection
use_js_obj_proxy: true
@ -17,6 +23,8 @@ enable_memento: true
# Replay content in an iframe
framed_replay: true
redirect_to_exact: true
# uncomment and change to set default locale
# default_locale: en

View File

@ -363,6 +363,9 @@ class FrontEndApp(object):
else:
coll_config['metadata'] = self.metadata_cache.load(coll) or {}
if 'ui' in self.warcserver.config:
coll_config['ui'] = self.warcserver.config['ui']
return coll_config
def serve_coll_page(self, environ, coll='$root'):

View File

@ -532,6 +532,7 @@ class RewriterApp(object):
coll=kwargs.get('coll', ''),
replay_mod=self.replay_mod,
metadata=kwargs.get('metadata', {}),
ui=kwargs.get('ui', {}),
config=self.config))
cookie_rewriter = None
@ -802,7 +803,8 @@ class RewriterApp(object):
prefix = self.get_full_prefix(environ)
params = dict(url=wb_url.url,
prefix=prefix)
prefix=prefix,
ui=kwargs.get('ui', {}))
return self.query_view.render_to_string(environ, **params)
@ -909,7 +911,9 @@ class RewriterApp(object):
pass
def get_top_frame_params(self, wb_url, kwargs):
return {'metadata': kwargs.get('metadata', {})}
return {'metadata': kwargs.get('metadata', {}),
'ui': kwargs.get('ui', {})
}
def handle_custom_response(self, environ, wb_url, full_prefix, host_prefix, kwargs):
if self.is_framed_replay(wb_url):

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -21,9 +21,11 @@ This file is part of pywb, https://github.com/webrecorder/pywb
// Creates the default pywb banner.
(function() {
if (window.top !== window) {
return;
}
try {
if (window.parent !== window && window.parent.wbinfo) {
return;
}
} catch (e) { }
/**
* The default banner class

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

14653
pywb/static/vue/vueui.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,11 @@
body {
display: flex;
flex-direction: column;
}
#wb_iframe_div, #replay_iframe {
width: 100%;
height: 100%;
}

View File

@ -231,7 +231,13 @@ ContentFrame.prototype.initBannerUpdateCheck = function(newUrl, newTs) {
* operating in live mode
*/
ContentFrame.prototype.load_url = function(newUrl, newTs) {
this.iframe.src = this.make_url(newUrl, newTs, true);
var newUrl = this.make_url(newUrl, newTs, true);
if (this.iframe.src === newUrl) {
return;
}
this.iframe.src = newUrl;
if (this.wbBanner) {
this.initBannerUpdateCheck(newUrl, newTs);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -17,13 +17,33 @@ window.banner_info = {
locales: {{ locales }},
locale_prefixes: {{ get_locale_prefixes() | tojson }},
prefix: "{{ wb_prefix }}",
staticPrefix: "{{ static_prefix }}"
staticPrefix: "{{ static_prefix }}",
logo: "{{ ui.logo }}"
};
</script>
{% if is_framed or not ui.vue_timeline_banner %}
<!-- default banner, create through js -->
<script src='{{ static_prefix }}/default_banner.js'> </script>
<link rel='stylesheet' href='{{ static_prefix }}/default_banner.css'/>
<script src='{{ static_prefix }}/default_banner.js'> </script>
{% else %}
<link rel='stylesheet' href='{{ static_prefix }}/vue_banner.css'/>
<script src="{{ static_prefix }}/vue/vueui.js"></script>
<script>
VueUI.main("{{ static_prefix }}", "{{ url }}", "{{ wb_prefix }}", "{{ timestamp }}");
</script>
{% if ui.vue_timeline_banner %}
<div id="app" style="width: 100%; height: 200px"></div>
{% endif %}
{% endif %}
{% endautoescape %}
{% endif %}

View File

@ -6,13 +6,24 @@
{% block head %}
{{ super() }}
{% if not ui.vue_calendar_ui %}
<link rel="stylesheet" href="{{ static_prefix }}/css/query.css">
<script src="{{ static_prefix }}/js/url-polyfill.min.js"></script>
<script src="{{ static_prefix }}/query.js"></script>
{% else %}
<script src="{{ static_prefix }}/vue/vueui.js"></script>
{% endif %}
{% endblock %}
{% block body %}
{% if not ui.vue_calendar_ui %}
<div class="container-fluid">
<div class="row justify-content-center">
<h4 class="display-4 text-center text-sm-left p-0">{{ _('Search Results') }}</h4>
@ -22,6 +33,9 @@
<div class="row justify-content-center text-center text-sm-left mt-1" id="display-query-type-info"></div>
</div>
<div class="container mt-3 q-display" id="captures"></div>
{% endif %}
<script>
var text = {
months: {
@ -55,7 +69,20 @@
},
};
{% if not ui.vue_calendar_ui %}
var renderCal = new RenderCalendar({ prefix: "{{ prefix }}", staticPrefix: "{{ static_prefix }}", text: text });
renderCal.init();
{% else %}
VueUI.main("{{ static_prefix }}", "{{ url }}", "{{ prefix }}");
{% endif %}
</script>
{% if ui.vue_calendar_ui %}
<div id="app" style="width: 100%; height: 100%"></div>
{% endif %}
{% endblock %}

39
pywb/vueui/.eslintrc.js Normal file
View File

@ -0,0 +1,39 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:vue/essential"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"vue"
],
"rules": {
"no-restricted-globals": [
2,
"event", "error"
],
"indent": [
"error",
2
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
]
}
};

21
pywb/vueui/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "typescript",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "rollup -c",
"lint": "eslint ./src/*.js ./src/*.vue ./src/components/*.vue"
},
"dependencies": {
"vue": "^2.6.11",
"vue-template-compiler": "^2.6.14"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^13.0.4",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^7.17.0",
"rollup": "^2.10.9",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-vue": "^5.0.0"
}
}

View File

@ -0,0 +1,20 @@
import vue from "rollup-plugin-vue";
import css from "rollup-plugin-css-only";
import { nodeResolve } from "@rollup/plugin-node-resolve";
export default [
{
input: "src/index.js",
output: {
file: "../static/vue/vueui.js",
sourcemap: "inline",
name: "VueUI",
format: "iife",
},
plugins: [
vue({css: true, compileTemplate: true}),
css(),
nodeResolve({browser: true})
],
},
];

245
pywb/vueui/src/App.vue Normal file
View File

@ -0,0 +1,245 @@
<template>
<div class="app" :class="{expanded: showTimelineView}" data-app="webrecorder-replay-app">
<div class="banner">
<div class="line">
<div class="logo"><img :src="config.logoImg" /></div>
<div class="timeline-wrap">
<div class="line">
<div class="breadcrumbs-wrap">
<TimelineBreadcrumbs
v-if="currentPeriod && showTimelineView"
:period="currentPeriod"
@goto-period="gotoPeriod"
></TimelineBreadcrumbs>
<span v-if="!showTimelineView" v-html="'&nbsp;'"></span><!-- for spacing -->
</div>
<div class="toggles">
<span class="toggle" :class="{expanded: showFullView}" @click="showFullView = !showFullView" :title="(showTimelineView ? 'show':'hide') + ' calendar'">
<img src="/static/calendar-icon.png" />
</span>
<span class="toggle" :class="{expanded: showTimelineView}" @click="showTimelineView = !showTimelineView" :title="(showTimelineView ? 'show':'hide') + ' timeline'">
<img src="/static/timeline-icon.png" />
</span>
</div>
</div>
<Timeline
v-if="currentPeriod && showTimelineView"
:period="currentPeriod"
:highlight="timelineHighlight"
:current-snapshot="currentSnapshot"
:max-zoom-level="maxTimelineZoomLevel"
@goto-period="gotoPeriod"
></Timeline>
</div>
</div>
</div>
<div class="snapshot-title">
<div>{{ config.url }}</div>
<div v-if="currentSnapshot && !showFullView">
<span v-if="config.title">{{ config.title }}</span>
Current capture: {{currentSnapshot.getTimeDateFormatted()}}
</div>
</div>
<CalendarYear v-if="showFullView"
:period="currentPeriod"
:current-snapshot="currentSnapshot"
@goto-period="gotoPeriod">
</CalendarYear>
</div>
</template>
<script>
import Timeline from "./components/Timeline.vue";
import TimelineBreadcrumbs from "./components/TimelineBreadcrumbs.vue";
import CalendarYear from "./components/CalendarYear.vue";
import { PywbSnapshot, PywbPeriod } from "./model.js";
export default {
name: "PywbReplayApp",
//el: '[data-app="webrecorder-replay-app"]',
data: function() {
return {
snapshots: [],
currentPeriod: null,
currentSnapshot: null,
msgs: [],
showFullView: false,
showTimelineView: true,
maxTimelineZoomLevel: PywbPeriod.Type.day,
config: {
title: "",
initialView: {}
},
timelineHighlight: false
};
},
components: {Timeline, TimelineBreadcrumbs, CalendarYear},
mounted: function() {
this.init();
},
computed: {
sessionStorageUrlKey() {
// remove http(s), www and trailing slash
return 'zoom__' + this.config.url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
}
},
methods: {
gotoPeriod: function(newPeriod, onlyZoomToPeriod) {
if (this.timelineHighlight) {
setTimeout((() => {
this.timelineHighlight=false;
}).bind(this), 3000);
}
// only go to snapshot if caller did not request to zoom only
if (newPeriod.snapshot && !onlyZoomToPeriod) {
this.gotoSnapshot(newPeriod.snapshot, newPeriod);
} else {
// save current period (aka zoom)
// use sessionStorage (not localStorage), as we want this to be a very temporary memory for current page tab/window and no longer; NOTE: it serves when navigating from an "*" query to a specific capture and subsequent reloads
if (window.sessionStorage) {
window.sessionStorage.setItem(this.sessionStorageUrlKey, newPeriod.fullId);
}
// If new period goes beyond allowed max level
if (newPeriod.type > this.maxTimelineZoomLevel) {
this.currentPeriod = newPeriod.get(this.maxTimelineZoomLevel);
} else {
this.currentPeriod = newPeriod;
}
}
},
gotoSnapshot(snapshot, fromPeriod) {
this.currentSnapshot = snapshot;
// if the current period is not matching the current snapshot, updated it!
if (fromPeriod && !this.currentPeriod.contains(fromPeriod)) {
const fromPeriodAtMaxZoomLevel = fromPeriod.get(this.maxTimelineZoomLevel);
if (fromPeriodAtMaxZoomLevel !== this.currentPeriod) {
this.currentPeriod = fromPeriodAtMaxZoomLevel;
}
}
// COMMUNICATE TO ContentFrame
this.$emit("show-snapshot", snapshot);
this.showFullView = false;
},
init() {
this.config.url = this.config.initialView.url;
if (this.config.initialView.title) {
this.config.title = this.config.initialView.title;
}
if (this.config.initialView.timestamp === undefined) {
this.showFullView = true;
this.showTimelineView = true;
} else {
this.showFullView = false;
this.showFullView = true;
this.setSnapshot(this.config.initialView);
}
if (window.sessionStorage) {
const currentPeriodId = window.sessionStorage.getItem(this.sessionStorageUrlKey);
if (currentPeriodId) {
const newCurrentPeriodFromStorage = this.currentPeriod.findByFullId(currentPeriodId);
if (newCurrentPeriodFromStorage) {
this.currentPeriod = newCurrentPeriodFromStorage;
}
}
}
},
setSnapshot(view) {
// convert to snapshot objec to support proper rendering of time/date
const snapshot = new PywbSnapshot(view, 0);
this.config.url = view.url;
this.config.title = view.title;
this.gotoSnapshot(snapshot);
}
}
};
</script>
<style>
.app {
font-family: Calibri, Arial, sans-serif;
border-bottom: 1px solid lightcoral;
width: 100%;
}
.app.expanded {
height: 150px;
}
.full-view {
/*position: fixed;*/
/*top: 150px;*/
left: 0;
}
.iframe iframe {
width: 100%;
height: 80vh;
}
.logo {
margin-right: 30px;
width: 180px;
}
.banner {
width: 100%;
max-width: 1200px; /* limit width */
position: relative;
}
.banner .line {
display: flex;
justify-content: flex-start;
}
.banner .logo {
flex-shrink: initial;
/* for any content/image inside the logo container */
display: flex;
align-items: center;
justify-content: center;
}
.banner .logo img {
flex-shrink: 1;
}
.banner .timeline-wrap {
flex-grow: 2;
overflow-x: hidden;
text-align: left;
margin: 0 25px;
position: relative;
}
.timeline-wrap .line .breadcrumbs-wrap {
display: inline-block;
flex-grow: 1;
}
.timeline-wrap .line .toggles {
display: inline-block;
flex-shrink: 1;
}
.toggles > .toggle {
display: inline-block;
border-radius: 5px;
padding: 0 4px;
height: 100%;
cursor: zoom-in;
}
.toggles > .toggle > img {
height: 18px;
display: inline-block;
margin-top: 2px;
}
.toggles .toggle:hover {
background-color: #eeeeee;
}
.toggles .toggle.expanded {
background-color: #eeeeee;
cursor: zoom-out;
}
.snapshot-title {
text-align: center;
font-weight: bold;
font-size: 16px;
}
</style>

View File

@ -0,0 +1,173 @@
<style>
.calendar-month {
position: relative;
display: inline-block;
padding: 5px;
margin: 0;
height: 260px;
width: 220px;
text-align: center;
vertical-align: top;
}
.calendar-month:hover {
background-color: #eeeeee;
border-radius: 10px;
}
.calendar-month.current {
background-color: #fff7ce;
border-radius: 5px;
}
.calendar-month.contains-current-snapshot {
border: solid 1px red;
}
.calendar-month > h3 {
margin: 0;
font-size: 16px;
}
.calendar-month > .empty {
position: absolute;
top: 45%;
width: 100%;
color: gray;
}
.calendar-month .day {
position: relative;
display: inline-block;
margin: 0;
text-align: center;
}
.calendar-month .day.empty {
color: gray;
}
.calendar-month .day .count {
display: none;
position: absolute;
bottom: 80%;
left: 80%;
line-height: 1; /* reset to normal */
padding: 3px;
border-radius: 10px;
border-bottom-left-radius: 0;
border: 1px solid gray;
background-color: white;
z-index: 30;
white-space: nowrap;
}
.calendar-month .day:hover .count {
display: block;
}
.calendar-month .day .size {
position: absolute;
box-sizing: border-box;
background-color: rgba(166, 205, 245, .85);
z-index: 10;
}
.calendar-month .day.contains-current-snapshot .size {
background-color: rgba(255, 100, 100, .85);
}
.calendar-month .day .day-id {
position: absolute;
top: 0;
left: 0;
z-index: 11;
display: inline-block;
width: 100%;
text-align: center;
color: black;
}
.calendar-month .day:hover .size {
border: 1px solid black;
}
.calendar-month .day:hover {
cursor: zoom-in;
}
.calendar-month .day.empty:hover {
cursor: not-allowed;
}
</style>
<template>
<div class="calendar-month" :class="{current: isCurrent, 'contains-current-snapshot': containsCurrentSnapshot}">
<h3>{{month.getReadableId()}} <span v-if="month.snapshotCount">({{ month.snapshotCount }})</span></h3>
<div v-if="month.snapshotCount">
<span v-for="(day) in ['S', 'M', 'T', 'W', 'H', 'F', 'S']" class="day" :style="dayStyle">{{day}}</span><br/>
<span v-for="(day,i) in days"><br v-if="i && i % 7===0"/><span class="day" :class="{empty: !day || !day.snapshotCount, 'contains-current-snapshot':dayContainsCurrentSnapshot(day)}" :style="dayStyle" @click="gotoDay(day, $event)"><template v-if="day"><span class="size" v-if="day.snapshotCount" :style="getDayCountCircleStyle(day.snapshotCount)"> </span><span class="day-id">{{day.id}}</span><span v-if="day.snapshotCount" class="count">{{day.snapshotCount}} capture<span v-if="day.snapshotCount!==1">s</span></span></template><template v-else v-html="'&nbsp;'"></template></span></span>
</div>
<div v-else class="empty">no captures</div>
</div>
</template>
<script>
import {PywbPeriod} from "../model";
export default {
props: ["month", "year", "isCurrent", "yearContainsCurrentSnapshot", "currentSnapshot"],
data: function() {
return {
maxInDay: 0,
daySize: 30,
};
},
computed: {
dayStyle() {
const s = this.daySize;
return `height: ${s}px; width: ${s}px; line-height: ${s}px`;
},
days() {
if (!this.month || !this.month.snapshotCount) {
return [];
}
const days = [];
// Get days in month, and days in the complete weeks before first day and after last day
const [firstDay, lastDay] = this.month.getChildrenRange();
const daysBeforeFirst = (new Date(this.year.id, this.month.id-1, firstDay)).getDay();
const daysAfterLastDay = (6 - (new Date(this.year.id, this.month.id-1, lastDay)).getDay());
for(let i=0; i<daysBeforeFirst; i++) {
days.push(null);
}
const hasChildren = !!this.month.children.length;
for(let i=0; i<lastDay; i++) {
days.push(hasChildren ? this.month.children[i] : null);
}
for(let i=0; i<daysAfterLastDay; i++) {
days.push(null);
}
return days;
},
containsCurrentSnapshot() {
return this.currentSnapshot &&
this.month.contains(this.currentSnapshot);
}
},
methods: {
gotoDay(day, event) {
if (!day || !day.snapshotCount) {
return;
}
// upon doing to day, tell timeline to highlight itself
// this.$root.timelineHighlight = true;
this.$emit("show-day-timeline", day, event);
},
getDayCountCircleStyle(snapshotCount) {
const size = Math.ceil((snapshotCount/this.year.maxGrandchildSnapshotCount) * this.daySize);
const scaledSize = size ? (this.daySize*.3 + Math.ceil(size*.7)) : 0;
const margin = (this.daySize-scaledSize)/2;
// TEMPORARILY DISABLE AUTO-HUE calculation as it is contributing to better understand of data
// color hue should go form blue (240deg) to red (360deg)
// const colorHue = Math.ceil((snapshotCount/this.year.maxGrandchildSnapshotCount) * (360-240));
// const scaledColorHue = size ? (240 + colorHue) : 240;
// background-color: hsl(${scaledColorHue}, 100%, 50%, .2)
return `width: ${scaledSize}px; height: ${scaledSize}px; top: ${margin}px; left: ${margin}px; border-radius: ${scaledSize/2}px;`;
},
dayContainsCurrentSnapshot(day) {
return !!day && day.snapshotCount > 0 && this.containsCurrentSnapshot && day.contains(this.currentSnapshot);
}
}
};
</script>

View File

@ -0,0 +1,140 @@
<style>
.full-view {
position: fixed;
z-index: 10;
height: 80vh;
overflow: scroll;
width: 100%;
background-color: white;
}
.full-view .months {
display: flex;
justify-content: center;
flex-wrap: wrap;
align-items: flex-start;
}
.full-view h2 {
margin: 10px 0;
font-size: 20px;
text-align: center;
}
</style>
<template>
<div class="full-view">
<h2>{{year.id}} ({{year.snapshotCount}} captures)</h2>
<div class="months">
<CalendarMonth
v-for="month in year.children"
:key="month.id"
:month="month"
:year="year"
:current-snapshot="containsCurrentSnapshot ? currentSnapshot : null"
:is-current="month === currentMonth"
@goto-period="$emit('goto-period', $event)"
@show-day-timeline="setCurrentTimeline"
></CalendarMonth>
</div>
<Tooltip :position="currentTimelinePos" v-if="currentTimelinePeriod" ref="timelineLinearTooltip">
<TimelineLinear
:period="currentTimelinePeriod"
:current-snapshot="containsCurrentSnapshot ? currentSnapshot : null"
@goto-period="gotoPeriod"
></TimelineLinear>
</Tooltip>
</div>
</template>
<script>
import CalendarMonth from "./CalendarMonth.vue";
import TimelineLinear from "./TimelineLinear.vue";
import Tooltip from "./Tooltip.vue";
import { PywbPeriod } from "../model.js";
export default {
components: {CalendarMonth, TimelineLinear, Tooltip},
props: ["period", "currentSnapshot"],
data: function() {
return {
firstZoomLevel: PywbPeriod.Type.day,
currentTimelinePeriod: null,
currentTimelinePos: '0,0'
};
},
mounted() {
document.querySelector('body').addEventListener('click', this.resetCurrentTimeline);
},
computed: {
year() { // the year that the timeline period is in
let year = null;
// if timeline is showing all year
if (this.period.type === PywbPeriod.Type.all) {
// if no current snapshot => pick the LAST YEAR
if (!this.currentSnapshot) {
year = this.period.children[this.period.children.length-1];
} else {
year = this.period.findByFullId(String(this.currentSnapshot.year));
}
} else if (this.period.type === PywbPeriod.Type.year) {
year = this.period;
} else {
year = this.period.getParents().filter(p => p.type === PywbPeriod.Type.year)[0];
}
if (year) {
year.fillEmptyChildPeriods(true);
}
return year;
},
currentMonth() { // the month that the timeline period is in
let month = null;
if (this.period.type === PywbPeriod.Type.month) {
month = this.period;
} else {
month = this.period.getParents().filter(p => p.type === PywbPeriod.Type.month)[0];
}
return month;
},
containsCurrentSnapshot() {
return this.currentSnapshot &&
this.year.contains(this.currentSnapshot);
}
},
methods: {
resetCurrentTimeline(event) {
if (event && this.$refs.timelineLinearTooltip) {
let el = event.target;
let clickWithinTooltip = false;
while(el.parentElement) {
if (el === this.$refs.timelineLinearTooltip.$el) {
clickWithinTooltip = true;
break;
}
el = el.parentElement;
}
if (!clickWithinTooltip) {
this.currentTimelinePeriod = null;
}
}
},
setCurrentTimeline(day, event) {
this.currentTimelinePeriod = day;
if (!day) {
return;
}
this.currentTimelinePos = `${event.x},${event.y}`;
event.stopPropagation();
event.preventDefault();
},
gotoPeriod(period) {
if (period.snapshot || period.snapshotPeriod) {
this.$emit('goto-period', period);
} else {
this.currentTimelinePeriod = period;
}
},
}
};
</script>

View File

@ -0,0 +1,151 @@
<template>
<div class="ruler">
<div class="inner" :class="{hidden: !isShow}">
<div class="axis x" :style="{width: xAxisWidth}" ref="xAxis">
<div class="tick x" v-for="x in xTicks" >{{ x ? x : '' }}</div>
<div class="line x" v-if="xAxisLine" :style="{left: xAxisLine+'px'}"><span>{{xAxisLine}}</span></div>
</div>
<div class="axis y" :style="{height: yAxisHeight}" ref="yAxis">
<div class="tick y" v-for="y in yTicks">{{ y ? y : '' }}</div>
<div class="line y" v-if="yAxisLine" :style="{top: yAxisLine+'px'}"><span>{{yAxisLine}}</span></div>
</div>
</div>
<span class="toggle" @click="isShow=!isShow">R</span>
</div>
</template>
<style>
.ruler {
position: fixed;
margin: 0;
padding: 0;
z-index: 90;
}
.ruler .inner.hidden { display: none; }
.ruler .toggle {
cursor: pointer;
z-index: 91;
position: fixed;
top: 0;
left: 0;
}
.ruler .line {
position: fixed;
top: 0;
left: 0;
width: 10px;
height: 10px;
}
.ruler .line span {
color: red;
font-weight: bold;
background-color: rgba(255,255,255,.9);
}
.ruler .tick {
font-size: 15px; line-height: 1;
}
.ruler .axis {
background-color: rgba(255,255,255,.9);
position: fixed;
top: 0;
left: 0;
white-space: nowrap;
overflow: visible;
}
.ruler .axis.x { height: 15px;}
.ruler .tick.x {
display: inline-block;
height: 15px;
width: 49px;
border-left: 1px solid black;
}
.ruler .axis.y { width: 15px; }
.ruler .tick.y {
display: block;
width: 15px;
height: 49px;
border-top: 1px solid black;
}
.ruler .line.x {
height: 100vh;
border-left: 1px solid red;
}
.ruler .line.y {
width: 100vw;
border-top: 1px solid red;
}
</style>
<script>
export default {
data() {
return {
isShow: false,
xTicks: [],
xAxisLine: 0,
yTicks: [],
yAxisLine: 0,
increment: 50 //px
}
},
mounted() {
for (let i = 0; i < 100; i++) {
this.xTicks.push(i * this.increment);
this.yTicks.push(i * this.increment);
}
const onMouseMoveHandlerFn_ = (event) => {
if (event.currentTarget === this.$refs.xAxis) {
this.xAxisLine = event.x;
console.log(event.x);
} else if (event.currentTarget === this.$refs.yAxis) {
this.yAxisLine = event.y;
console.log(event.y);
}
};
const onMouseMoveHandlerFn = onMouseMoveHandlerFn_.bind(this);
const toggleHandlers = (event) => {
if (event.currentTarget === this.$refs.xAxis) {
if (this.$refs.xAxis.isGuidelineOn) {
this.$refs.xAxis.isGuidelineOn = false;
this.xAxisLine = 0;
this.$refs.xAxis.removeEventListener('mousemove', onMouseMoveHandlerFn, true);
} else {
this.$refs.xAxis.isGuidelineOn = true;
this.xAxisLine = event.x;
this.$refs.xAxis.addEventListener('mousemove', onMouseMoveHandlerFn, true);
}
event.stopPropagation();
} else if (event.currentTarget === this.$refs.yAxis) {
if (this.$refs.yAxis.isGuidelineOn) {
this.$refs.yAxis.isGuidelineOn = false;
this.yAxisLine = 0;
this.$refs.yAxis.removeEventListener('mousemove', onMouseMoveHandlerFn, true);
} else {
this.$refs.yAxis.isGuidelineOn = true;
this.yAxisLine = event.y;
this.$refs.yAxis.addEventListener('mousemove', onMouseMoveHandlerFn, true);
}
event.stopPropagation();
}
}
this.$refs.xAxis.addEventListener('click', toggleHandlers.bind(this), true);
this.$refs.yAxis.addEventListener('click', toggleHandlers.bind(this), true);
},
computed: {
yAxisHeight() {
return `${this.yTicks.length * this.increment}px`;
},
xAxisWidth() {
return `${this.xTicks.length * this.increment}px`;
}
},
methods: {
}
};
</script>

View File

@ -0,0 +1,431 @@
<template>
<div class="timeline">
<div class="period-tooltip" v-show="tooltipPeriod" :style="{left: tooltipPeriodPos.x+'px', top: tooltipPeriodPos.y+'px'}">
<template v-if="tooltipPeriod">
<div v-if="tooltipPeriod.snapshot">View capture on
{{ tooltipPeriod.snapshot.getTimeDateFormatted() }}
</div>
<div v-else-if="tooltipPeriod.snapshotPeriod">View capture on
{{ tooltipPeriod.snapshotPeriod.snapshot.getTimeDateFormatted() }}
</div>
<div v-else-if="tooltipPeriod.snapshotCount">
{{ tooltipPeriod.snapshotCount }} captures
<span v-if="isTooltipPeriodDayOrHour">on</span><span v-else>in</span>
{{ tooltipPeriod.getFullReadableId() }}
</div>
</template>
</div>
<div v-html="'&#x25C0;'" class="arrow previous" :class="{disabled: isScrollZero && !previousPeriod}" @click="scrollPrev" @dblclick.stop.prevent></div>
<div class="scroll" ref="periodScroll" :class="{highlight: highlight}">
<div class="periods" ref="periods">
<div v-for="subPeriod in period.children"
:key="subPeriod.id"
class="period"
:class="{empty: !subPeriod.snapshotCount, highlight: highlightPeriod === subPeriod, 'last-level': !canZoom, 'contains-current-snapshot': containsCurrentSnapshot(subPeriod) }"
>
<div class="histo">
<div class="line"
v-for="histoPeriod in subPeriod.children"
:key="histoPeriod.id"
:style="{height: getHistoLineHeight(histoPeriod.snapshotCount)}"
:class="{'has-single-snapshot': histoPeriod.snapshotCount === 1, 'contains-current-snapshot': containsCurrentSnapshot(histoPeriod)}"
@click="changePeriod(histoPeriod, $event)"
@mouseover="setTooltipPeriod(histoPeriod, $event)"
@mouseout="setTooltipPeriod(null, $event)"
>
</div>
</div>
<div class="inner"
:class="{'has-single-snapshot': subPeriod.snapshotCount === 1}"
@click="changePeriod(subPeriod, $event)"
@mouseover="setTooltipPeriod(subPeriod, $event)"
@mouseout="setTooltipPeriod(null, $event)"
>
<div class="label">
{{subPeriod.getReadableId()}}
</div>
</div>
</div>
</div>
</div>
<div v-html="'&#x25B6;'" class="arrow next" :class="{disabled: isScrollMax && !nextPeriod}" @click="scrollNext" @dblclick.stop.prevent></div>
</div>
</template>
<script>
import { PywbPeriod } from "../model.js";
export default{
props: {
period: { required: true },
currentSnapshot: { required: false, default: null},
highlight: { required: false, default: false},
stayWithinPeriod: { required: false, default: false},
maxZoomLevel: { required: false, default: PywbPeriod.Type.snapshot}
},
data: function() {
return {
highlightPeriod: null,
previousPeriod: null,
nextPeriod: null,
isScrollZero: true,
isScrollMax: true,
tooltipPeriod: null,
tooltipPeriodPos: {x:0,y:0}
};
},
created: function() {
this.addEmptySubPeriods();
},
mounted: function() {
this.$refs.periods._computedStyle = window.getComputedStyle(this.$refs.periods);
this.$refs.periodScroll._computedStyle = window.getComputedStyle(this.$refs.periodScroll);
this.$watch("period", this.onPeriodChanged);
this.$refs.periodScroll.addEventListener("scroll", this.updateScrollArrows);
window.addEventListener("resize", this.updateScrollArrows);
this.updateScrollArrows();
},
computed: {
// this determins which the last zoom level is before we go straight to showing snapshot
canZoom() {
return this.period.type < this.maxZoomLevel;
},
isTooltipPeriodDayOrHour() {
return this.tooltipPeriod.type >= PywbPeriod.Type.day;
},
iContainCurrentSnapshot() {
return this.currentSnapshot && this.period.contains(this.currentSnapshot);
}
},
updated() {
// do something on update
},
methods: {
containsCurrentSnapshot(period) {
return this.iContainCurrentSnapshot && period.contains(this.currentSnapshot);
},
addEmptySubPeriods() {
this.period.fillEmptyChildPeriods(true);
},
updateScrollArrows() {
this.period.scroll = this.$refs.periodScroll.scrollLeft;
const maxScroll = parseInt(this.$refs.periods._computedStyle.width) - parseInt(this.$refs.periodScroll._computedStyle.width);
this.isScrollZero = !this.period.scroll; // if 0, then true (we are at scroll zero)
this.isScrollMax = Math.abs(maxScroll - this.period.scroll) < 5;
},
restoreScroll() {
this.$refs.periodScroll.scrollLeft = this.period.scroll;
},
scrollNext: function () {
if (this.isScrollMax) {
if (this.nextPeriod) {
this.$emit("goto-period", this.nextPeriod, true /* onlyZoomToPeriod */);
}
} else {
this.$refs.periodScroll.scrollLeft += 30;
}
},
scrollPrev: function () {
if (this.isScrollZero) {
if (this.previousPeriod) {
this.$emit("goto-period", this.previousPeriod, true /* onlyZoomToPeriod */);
}
} else {
this.$refs.periodScroll.scrollLeft -= 30;
}
},
getTimeFormatted: function(date) {
return (date.hour < 13 ? date.hour : (date.hour % 12)) + ":" + ((date.minute < 10 ? "0":"")+date.minute) + " " + (date.hour < 12 ? "am":"pm");
},
getHistoLineHeight: function(value) {
const percent = Math.ceil((value/this.period.maxGrandchildSnapshotCount) * 100);
return (percent ? (5 + Math.ceil(percent*.95)) : 0) + "%";
// return percent + '%';
},
changePeriod(period, $event) {
// if not empty
if (period.snapshotCount) {
let periodToChangeTo = null;
// if contains a single snapshot only, navigate to snapshot (load snapshot in FRAME, do not ZOOM IN)
if (period.snapshot) {
// if period is at level "snapshot" (no more children), send period, else send the child period, a reference to which is stored (by data/model layer) in the current period; App event needs a period to be passed (cannot pass in snapshot object itself)
if (period.type === PywbPeriod.Type.snapshot) {
periodToChangeTo = period;
} else if (period.snapshotPeriod) {
periodToChangeTo = period.snapshotPeriod;
}
} else {
// if contains mulitple snapshots,
// zoom if ZOOM level is day or less, OR if period contain TOO MANY (>10)
if (this.canZoom) {
periodToChangeTo = period;
}
}
// if we selected a period to go to, emit event
if (periodToChangeTo) {
this.$emit("goto-period", periodToChangeTo);
}
}
$event.stopPropagation();
return false;
},
onPeriodChanged(newPeriod, oldPeriod) {
this.addEmptySubPeriods();
const previousPeriod = this.period.getPrevious();
const nextPeriod = this.period.getNext();
if (!this.stayWithinPeriod || this.stayWithinPeriod.contains(previousPeriod)) {
this.previousPeriod = previousPeriod;
}
if (!this.stayWithinPeriod || this.stayWithinPeriod.contains(nextPeriod)) {
this.nextPeriod = nextPeriod;
}
// detect if going up level of period (new period type should be in old period parents)
if (oldPeriod && oldPeriod.type - newPeriod.type > 0) {
let highlightPeriod = oldPeriod;
for (let i=oldPeriod.type - newPeriod.type; i > 1; i--) {
highlightPeriod = highlightPeriod.parent;
}
this.highlightPeriod = highlightPeriod;
setTimeout((function() {
this.highlightPeriod = null;
}).bind(this), 2000);
}
setTimeout((function() {
this.restoreScroll();
this.updateScrollArrows();
}).bind(this), 1);
},
setTooltipPeriod(period, event) {
if (!period || !period.snapshotCount) {
this.tooltipPeriod = null;
return;
}
this.tooltipPeriod = period;
this.$nextTick(function() {
const tooltipContentsEl = document.querySelector('.period-tooltip div');
if (!tooltipContentsEl) {
return;
}
const periodTooltipStyle = window.getComputedStyle(tooltipContentsEl);
const tooltipWidth = parseInt(periodTooltipStyle.width);
const tooltipHeight = parseInt(periodTooltipStyle.height);
const spacing = 10;
if (window.innerWidth < event.x + (spacing*2) + tooltipWidth) {
this.tooltipPeriodPos.x = event.x - (tooltipWidth + spacing);
} else {
this.tooltipPeriodPos.x = event.x + spacing;
}
this.tooltipPeriodPos.y = event.y - (spacing + tooltipHeight);
});
event.stopPropagation();
return false;
}
}
};
</script>
<style>
.timeline {
position: relative;
display: flex;
width: auto;
height: 60px;
margin: 5px;
justify-content: left;
}
.timeline .id {
display: inline-block;
font-size: 30px;
}
.timeline .arrow {
display: inline-block;
width: 20px;
font-size: 20px; /* font-size = width of arrow, as it UTF char */
line-height: 60px;
vertical-align: top;
cursor: pointer;
}
.timeline .arrow.previous {
}
.timeline .arrow.next {
}
.timeline .arrow.disabled, .timeline .arrow.disabled:hover {
/*color: lightgray;*/
background-color: transparent;
/*cursor: not-allowed;*/
visibility: hidden;
}
.timeline .arrow:hover {
background-color: antiquewhite;
color: firebrick;
}
.timeline .scroll {
position: relative;
display: inline-block;
width: 100%; /* */
height: 100%;
/* maker scrollable horizontally */
overflow-x: scroll;
overflow-y: hidden;
white-space: nowrap;
scroll-behavior: smooth;
text-align: center;
transition: background-color 500ms ease-in;
}
/* hide scroll bar */
.timeline .scroll::-webkit-scrollbar {
display: none;
}
/* highlight the scroll period: usually triggered from root app */
.timeline .scroll.highlight {
background-color: #fff7ce;
}
.timeline .scroll .periods {
display: flex;
justify-content: space-between;
height: 100%;
width: 100%;
min-width: 600px;
}
.timeline .period {
flex-grow: 1;
position: relative;
display: inline-block;
height: 100%;
/* line-height: 80px; /* use to center middle vertically */
white-space: normal;
vertical-align: top;
text-align: center;
background-color: transparent;
transition: background-color 500ms ease-in-out;
}
/* 1st period period child el */
.timeline .period:nth-child(1) {
/*border-left: 1px solid white; !* has left border; all other periods have right border *!*/
}
.timeline .period:hover {
background-color: #eeeeee;
}
.timeline .period.contains-current-snapshot, .timeline .period.contains-current-snapshot:hover {
background-color: #f7def4;
}
/* empty period */
.timeline .period.empty {
color: #aaa;
/*background-color: transparent;*/
}
/* highlighted period */
.timeline .period.highlight {
background-color: cyan;
}
.timeline .period .inner {
display: block;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 20px;
background-color: white;
border-top: 1px solid gray;
white-space: nowrap;
cursor: zoom-in;
}
.timeline .period .inner.has-single-snapshot {
cursor: pointer;
}
.timeline .period.last-level .inner, .timeline .period.empty .inner {
cursor: default;
}
.timeline .period .label {
width: 100%;
font-weight: bold;
font-size: 14px;
transition: background-color 500ms ease-in;
}
.timeline .period:hover .label {
position: absolute;
z-index: 20;
background-color: lightgrey;
}
.timeline .period .histo {
display: flex;
position: absolute;
top: 1px;
left: 0;
width: 100%;
height: 39px;
align-items: flex-end;
justify-content: space-between;
text-align: left;
}
.timeline .period .histo .line {
position: relative;
flex-grow: 1;
display: inline-block;
background-color: #a6cdf5;
margin: 0;
padding: 0;
cursor: zoom-in;
}
.timeline .period .histo .line.has-single-snapshot {
cursor: pointer;
}
/* Last level period histogram spaces things evenly */
.timeline .period.last-level .histo {
justify-content: space-around;
}
/* Last level period histogram lines do not grow, but are fixed width/margin */
.timeline .period.last-level .histo .line {
flex-grow: unset;
width: 5px;
margin-left: 2px;
}
/* update line color on hover*/
.timeline .period .histo .line:hover {
background-color: #f5a6eb;
}
.timeline .period .histo .line.contains-current-snapshot {
background-color: red;
}
/* Period that contains ONE snapshot only will show snapshot info*/
.timeline .period-tooltip {
position: fixed;
z-index: 100;
/*left or right set programmatically*/
display: block;
background-color: white;
border: 1px solid gray;
padding: 2px;
white-space: nowrap; /*no wrapping allowed*/
}
/*show on hover*/
.timeline .period-tooltip.show {
display: block;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<div class="breadcrumbs">
<template v-if="parents.length">
<span class="item">
<span class="goto" @click="changePeriod(parents[0])" :title="getPeriodZoomOutText(parents[0])">
<img src="/static/zoom-out-icon-333316.png" /> {{parents[0].getReadableId(true)}}
</span>
</span>
&gt;
<span v-for="(parent,i) in parents" :key="parent.id" class="item" v-if="i > 0">
<span class="goto" @click="changePeriod(parent)" :title="getPeriodZoomOutText(parent)">
{{parent.getReadableId(true)}}
</span>
&gt;
</span>
</template>
<span class="item">
<span class="current">{{period.getReadableId(true)}}</span>
<span class="count">({{period.snapshotCount}} capture<span v-if="period.snapshotCount !== 1">s</span>)</span>
</span>
</div>
</template>
<script>
export default {
props: {
period: {
required: true
}
},
computed: {
parents: function() {
return this.period.getParents();
}
},
methods: {
getPeriodZoomOutText(period) {
return 'Zoom out to '+period.getReadableId(true)+ ' ('+period.snapshotCount+' captures)';
},
changePeriod(period) {
if (period.snapshotCount) {
this.$emit("goto-period", period);
}
},
}
};
</script>
<style>
.breadcrumbs {
text-align: center;
}
.breadcrumbs .item {
position: relative;
display: inline;
margin: 0 2px 0 0;
font-size: inherit;
}
.breadcrumbs .count {
vertical-align: middle;
font-size: inherit;
}
.breadcrumbs .item .goto {
display: inline-block;
margin: 1px;
padding: 1px;
cursor: zoom-out;
border-radius: 5px;
background-color: #eeeeee;
}
.breadcrumbs .item .goto:hover {
background-color: #a6cdf5;
}
.breadcrumbs .item .goto img {
height: 15px;
}
.breadcrumbs .item.snapshot {
display: block;
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<div class="timeline-linear">
<div class="title">
<div>{{ period.getFullReadableId() }}</div>
<div>{{ period.snapshotCount }} capture<span v-if="period.snapshotCount > 1">s</span></div>
</div>
<div class="list">
<div v-for="period in snapshotPeriods">
<span class="link" @click="gotoPeriod(period)" >{{period.snapshot.getTimeFormatted()}}</span>
<span v-if="isCurrentSnapshot(period)" class="current">current</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: "TimelineLinear",
props: ['period', 'currentSnapshot'],
computed: {
snapshotPeriods() {
return this.period.getSnapshotPeriodsFlat();
},
containsCurrentSnapshot() {
return this.currentSnapshot &&
this.period.contains(this.currentSnapshot);
}
},
methods: {
gotoPeriod(period) {
this.$emit('goto-period', period);
},
isCurrentSnapshot(period) {
return this.currentSnapshot && this.currentSnapshot.id === period.snapshot.id;
}
}
}
</script>
<style scoped>
.timeline-linear {
width: auto;
padding: 5px;
background-color: white;
border: 1px solid gray;
border-radius: 5px;
}
.timeline-linear .list {
max-height: 80vh;
min-height: 50px;
overflow: scroll;
}
.timeline-linear .title {
border-bottom: 1px solid black;
font-weight: bold;
font-family: Arial, sans-serif;
}
.timeline-linear .link {
text-decoration: underline;
color: darkblue;
}
.timeline-linear .link:hover {
color: lightseagreen;
cursor: pointer;
}
.timeline-linear .current {
background-color: deeppink;
color: white;
border-radius: 5px;
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<div class="pywb-tooltip">
<slot></slot>
</div>
</template>
<script>
let elStyle = null;
export default {
name: "Tooltip",
props: ['position'],
mounted() {
this.$watch('position', this.updatePosition);
this.updatePosition();
},
methods: {
updatePosition() {
this.$el.style.top = 0;
this.$el.style.left = 0;
this.$el.style.maxHeight = 'auto';
const style = window.getComputedStyle(this.$el);
const width = parseInt(style.width);
const height = parseInt(style.height);
const spacing = 10;
const [initX, initY] = this.position.split(',').map(s => parseInt(s));
if (window.innerWidth < initX + (spacing*2) + width) {
this.$el.style.left = (initX - (width + spacing)) + 'px';
} else {
this.$el.style.left = (initX + spacing) + 'px';
}
if ((window.innerHeight < initY + (spacing*2) + height) && (initY - (spacing*2) - height < 0) ) {
if (initY > window.innerHeight / 2) {
this.$el.style.top = (window.innerHeight - (height + (spacing*2))) + 'px';
} else {
this.$el.style.top = (spacing*2) + 'px';
}
} else if (window.innerHeight < initY + (spacing*2) + height) {
this.$el.style.top = (initY - (spacing + height)) + 'px';
} else {
this.$el.style.top = initY + 'px';
}
}
}
}
</script>
<style scoped>
.pywb-tooltip {
position: fixed;
top: 0;
left: 0;
z-index: 40;
background-color: white;
border: 1px solid grey;
border-radius: 5px;
}
</style>

201
pywb/vueui/src/index.js Normal file
View File

@ -0,0 +1,201 @@
import appData from "./App.vue";
import { PywbData } from "./model.js";
import Vue from "vue/dist/vue.esm.browser";
// ===========================================================================
export function main(staticPrefix, url, prefix, timestamp) {
new CDXLoader(staticPrefix, url, prefix, timestamp);
}
// ===========================================================================
class CDXLoader {
constructor(staticPrefix, url, prefix, timestamp) {
this.opts = {};
this.prefix = prefix;
this.staticPrefix = staticPrefix;
this.isReplay = (timestamp !== undefined);
if (this.isReplay) {
window.WBBanner = new VueBannerWrapper(this, url);
}
let queryURL;
// query form *?=url...
if (window.location.href.indexOf("*?") > 0) {
queryURL = window.location.href.replace("*?", "cdx?") + "&output=json";
url = new URL(queryURL).searchParams.get("url");
// otherwise, traditional calendar form /*/<url>
} else if (url) {
const params = new URLSearchParams();
params.set("url", url);
params.set("output", "json");
queryURL = prefix + "cdx?" + params.toString();
// otherwise, an error since no URL
} else {
throw new Error("No query URL specified");
}
this.opts.initialView = {url, timestamp};
// TODO: make configurable
this.opts.logoImg = staticPrefix + "/pywb-logo-sm.png";
this.loadCDX(queryURL).then((cdxList) => {
this.app = this.initApp(cdxList, this.opts, (snapshot) => this.loadSnapshot(snapshot));
});
}
initApp(data, config = {}, loadCallback = null) {
const app = new Vue(appData);
const pywbData = new PywbData(data);
app.$set(app, "snapshots", pywbData.snapshots);
app.$set(app, "currentPeriod", pywbData.timeline);
app.$set(app, "config", {...app.config, ...config});
app.$mount("#app");
// TODO (Ilya): make this work with in-page snapshot/capture/replay updates!
// app.$on("show-snapshot", snapshot => {
// const replayUrl = app.config.url;
// const url = location.href.replace('/'+replayUrl, '').replace(/\d+$/, '') + snapshot.id + '/' + replayUrl;
// window.history.pushState({url: replayUrl, timestamp: snapshot.id}, document.title, url);
// if (!window.onpopstate) {
// window.onpopstate = (ev) => {
// updateSnapshot(ev.state.url, ev.state.timestamp);
// };
// }
// });
if (loadCallback) {
app.$on("show-snapshot", loadCallback);
}
return app;
}
async updateSnapshot(url, timestamp) {
const params = new URLSearchParams();
params.set("url", url);
params.set("output", "json");
const queryURL = this.prefix + "cdx?" + params.toString();
const cdxList = await this.loadCDX(queryURL);
const pywbData = new PywbData(cdxList);
const app = this.app;
app.$set(app, "snapshots", pywbData.snapshots);
app.$set(app, "currentPeriod", pywbData.timeline);
app.setSnapshot({url, timestamp});
}
async loadCDX(queryURL) {
const queryWorker = new Worker(this.staticPrefix + "/queryWorker.js");
const p = new Promise((resolve) => {
const cdxList = [];
queryWorker.addEventListener("message", (event) => {
const data = event.data;
switch (data.type) {
case "cdxRecord":
cdxList.push(data.record);
break;
case "finished":
resolve(cdxList);
break;
}
});
});
queryWorker.postMessage({
type: "query",
queryURL
});
const results = await p;
queryWorker.terminate();
//delete queryWorker;
return results;
}
loadSnapshot(snapshot) {
if (!this.isReplay) {
window.location.href = this.prefix + snapshot.id + "/" + snapshot.url;
} else if (window.cframe) {
window.cframe.load_url(snapshot.url, snapshot.id + "");
}
}
}
// ===========================================================================
class VueBannerWrapper
{
constructor(loader, url) {
this.loading = true;
this.lastSurt = this.getSurt(url);
this.loader = loader;
}
init() {}
stillIndicatesLoading() {
return this.loading;
}
updateCaptureInfo(/*url, ts, is_live*/) {
this.loading = false;
}
onMessage(event) {
const type = event.data.wb_type;
if (type === "load" || type === "replace-url") {
const surt = this.getSurt(event.data.url);
if (surt !== this.lastSurt) {
this.loader.updateSnapshot(event.data.url, event.data.ts);
this.lastSurt = surt;
}
}
}
getSurt(url) {
try {
if (!url.startsWith("https:") && !url.startsWith("http:")) {
return url;
}
url = url.replace(/^(https?:\/\/)www\d*\./, "$1");
const urlObj = new URL(url.toLowerCase());
const hostParts = urlObj.hostname.split(".").reverse();
let surt = hostParts.join(",");
if (urlObj.port) {
surt += ":" + urlObj.port;
}
surt += ")";
surt += urlObj.pathname;
if (urlObj.search) {
urlObj.searchParams.sort();
surt += urlObj.search;
}
return surt;
} catch (e) {
return url;
}
}
}

491
pywb/vueui/src/model.js Normal file
View File

@ -0,0 +1,491 @@
const PywbMonthLabels = {1:"Jan", 2:"Feb",3:"Mar",4:"Apr",5:"May",6:"Jun",7:"Jul",8:"Aug",9:"Sep",10:"Oct",11:"Nov",12:"Dec"};
const PywbPeriodIdDelimiter = '-';
export function PywbData(rawSnaps) {
const allTimePeriod = new PywbPeriod({type: PywbPeriod.Type.all, id: "all"});
const snapshots = [];
let lastSingle = null;
let lastYear, lastMonth, lastDay, lastHour;
rawSnaps.forEach((rawSnap, i) => {
const snap = new PywbSnapshot(rawSnap, i);
let year, month, day, hour, single;
if (!(year = allTimePeriod.getChildById(snap.year))) {
if (lastYear) lastYear.checkIfSingleSnapshotOnly();
lastYear = year = new PywbPeriod({type: PywbPeriod.Type.year, id: snap.year});
allTimePeriod.addChild(year);
}
if (!(month = year.getChildById(snap.month))) {
if (lastMonth) lastMonth.checkIfSingleSnapshotOnly();
lastMonth = month = new PywbPeriod({type: PywbPeriod.Type.month, id: snap.month});
year.addChild(month);
}
if (!(day = month.getChildById(snap.day))) {
if (lastDay) lastDay.checkIfSingleSnapshotOnly();
lastDay = day = new PywbPeriod({type: PywbPeriod.Type.day, id: snap.day});
month.addChild(day);
}
const hourValue = Math.ceil((snap.hour + .0001) / (24/8)); // divide day in 4 six-hour periods (aka quarters)
if (!(hour = day.getChildById(hourValue))) {
if (lastHour) lastHour.checkIfSingleSnapshotOnly();
lastHour = hour = new PywbPeriod({type: PywbPeriod.Type.hour, id: hourValue});
day.addChild(hour);
}
if (!(single = hour.getChildById(snap.id))) {
single = new PywbPeriod({type: PywbPeriod.Type.snapshot, id: snap.id});
hour.addChild(single);
}
single.setSnapshot(snap);
if (lastSingle) {
lastSingle.setNextSnapshotPeriod(single);
single.setPreviousSnapshotPeriod(lastSingle);
}
lastSingle = single;
snapshots.push(snap);
});
this.timeline = allTimePeriod;
this.snapshots = snapshots;
this.getSnapshot = function(index) {
if (index < 0 || index >= this.snapshots.length) {
return null;
}
return this.snapshots[index];
};
this.getPreviousSnapshot = function(snapshot) {
const index = snapshot.index;
return this.getSnapshot(index-1);
};
this.getNextSnapshot = function(snapshot) {
const index = snapshot.index;
return this.getSnapshot(index+1);
};
}
/* ---------------- SNAP SHOT object ----------------- */
export class PywbSnapshot {
constructor(init, index) {
this.index = index;
this.year = parseInt(init.timestamp.substr(0, 4));
this.month = parseInt(init.timestamp.substr(4, 2));
this.day = parseInt(init.timestamp.substr(6, 2));
this.hour = parseInt(init.timestamp.substr(8, 2));
this.minute = parseInt(init.timestamp.substr(10, 2));
this.second = parseInt(init.timestamp.substr(12, 2));
this.id = parseInt(init.timestamp);
this.urlkey = init.urlkey;
this.url = init.url;
this.mime = init.mime;
this.status = init.status;
this.digest = init.digest;
this.redirect = init.redirect;
this.robotflags = init.robotflags;
this.length = init.length;
this.offset = init.offset;
this.filename = init.filename;
this.load_url = init.load_url;
this["source-col"] = init["source-col"];
this.access = init.access;
}
getTimeDateFormatted() {
return `${PywbMonthLabels[this.month]} ${this.day}, ${this.year} at ${this.getTimeFormatted()}`;
}
getDateFormatted() {
return `${this.year}-${PywbMonthLabels[this.month]}-${this.day}`;
}
getTimeFormatted() {
return (this.hour < 13 ? this.hour : (this.hour % 12)) + ":" + ((this.minute < 10 ? "0":"")+this.minute) + ":" + ((this.second < 10 ? "0":"")+this.second) + " " + (this.hour < 12 ? "am":"pm");
}
getParentIds() {
return [this.year, this.month, this.day, Math.ceil((this.hour + .0001) / (24/8))];
}
getFullId() {
return [this.year, this.month, this.day, Math.ceil((this.hour + .0001) / (24/8)), this.id].join(PywbPeriodIdDelimiter);
}
}
/* ---------------- PERIOD object ----------------- */
export function PywbPeriod(init) {
this.type = init.type;
this.id = init.id;
this.fullId = ''; // full-id property that include string id of parents and self with a delimitor
this.childrenIds = {}; // allow for query by ID
this.children = []; // allow for sequentiality / order
this.maxGrandchildSnapshotCount = 0;
this.snapshotCount = 0;
}
PywbPeriod.Type = {all: 0,year: 1,month: 2,day: 3,hour: 4,snapshot:5};
PywbPeriod.TypeLabel = ["timeline","year","month","day","hour","snapshot"];
PywbPeriod.prototype.getTypeLabel = function() {
return PywbPeriod.TypeLabel[this.type];
};
PywbPeriod.GetTypeLabel = function(type) {
return PywbPeriod.TypeLabel[type] ? PywbPeriod.TypeLabel[type] : "";
};
PywbPeriod.prototype.getChildById = function(id) {
return this.children[this.childrenIds[id]];
};
// previous period (ONLY SET at the period level/type: snapshot)
PywbPeriod.prototype.getPreviousSnapshotPeriod = () => {};
PywbPeriod.prototype.setPreviousSnapshotPeriod = function(period) {
this.getPreviousSnapshotPeriod = () => period;
};
// next period (ONLY SET at the period level/type: snapshot)
PywbPeriod.prototype.getNextSnapshotPeriod = () => {};
PywbPeriod.prototype.setNextSnapshotPeriod = function(period) {
this.getNextSnapshotPeriod = () => period;
};
PywbPeriod.prototype.getFirstSnapshotPeriod = function() {
return this.getFirstLastSnapshotPeriod_("first");
};
PywbPeriod.prototype.getLastSnapshotPeriod = function() {
return this.getFirstLastSnapshotPeriod_("last");
};
PywbPeriod.prototype.getFirstLastSnapshotPeriod_ = function(direction) {
let period = this;
let iFailSafe = 100; // in case a parser has a bug and the snapshotCount is not correct; avoid infinite-loop
while (period.snapshotCount && period.type !== PywbPeriod.Type.snapshot) {
let i = 0;
for(i=0; i < period.children.length; i++) {
const ii = direction === "first" ? i : (period.children.length - 1 - i);
if (period.children[ii].snapshotCount) {
period = period.children[ii];
break;
}
}
if (iFailSafe-- < 0) {
break;
}
}
if (period.type === PywbPeriod.Type.snapshot && period.snapshot) {
return period;
}
return null;
};
PywbPeriod.prototype.getPrevious = function() {
const firstSnapshotPeriod = this.getFirstSnapshotPeriod();
if (!firstSnapshotPeriod) {
return null;
}
const previousSnapshotPeriod = firstSnapshotPeriod.getPreviousSnapshotPeriod();
if (!previousSnapshotPeriod) {
return null;
}
if (this.type === PywbPeriod.Type.snapshot) {
return previousSnapshotPeriod;
}
let parent = previousSnapshotPeriod.parent;
while(parent) {
if (parent.type === this.type) {
break;
}
parent = parent.parent;
}
return parent;
};
PywbPeriod.prototype.getNext = function() {
const lastSnapshotPeriod = this.getLastSnapshotPeriod();
if (!lastSnapshotPeriod) {
return null;
}
const nextSnapshotPeriod = lastSnapshotPeriod.getNextSnapshotPeriod();
if (!nextSnapshotPeriod) {
return null;
}
if (this.type === PywbPeriod.Type.snapshot) {
return nextSnapshotPeriod;
}
let parent = nextSnapshotPeriod.parent;
while(parent) {
if (parent.type === this.type) {
break;
}
parent = parent.parent;
}
return parent;
};
PywbPeriod.prototype.parent = null;
PywbPeriod.prototype.addChild = function(period) {
if (this.getChildById(period.id)) {
return false;
}
period.parent = this;
this.childrenIds[period.id] = this.children.length;
this.children.push(period);
period.initFullId();
return true;
};
PywbPeriod.prototype.getChildrenRange = function() {
switch (this.type) {
case PywbPeriod.Type.all:
// year range: first to last year available
return [this.children[0].id, this.children[this.children.length-1].id];
case PywbPeriod.Type.year:
// month is simple: 1 to 12
return [1,12];
case PywbPeriod.Type.month: {
// days in month: 1 to last day in month
const y = this.parent.id; const m = this.id;
const lastDateInMonth = (new Date((new Date(y, m, 1)).getTime() - 1000)).getDate(); // 1 sec earlier
return [1, lastDateInMonth];
}
case PywbPeriod.Type.day:
// hours: 0 to 23
// return [1,4];
return [1,8];
}
return null;
};
PywbPeriod.prototype.fillEmptyGrancChildPeriods = function() {
if (this.hasFilledEmptyGrandchildPeriods) {
return;
}
this.children.forEach(c => {
c.fillEmptyChildPeriods();
});
this.hasFilledEmptyGrandchildPeriods = true;
};
PywbPeriod.prototype.fillEmptyChildPeriods = function(isFillEmptyGrandChildrenPeriods=false) {
if (this.snapshotCount === 0 || this.type > PywbPeriod.Type.day) {
return;
}
if (isFillEmptyGrandChildrenPeriods) {
this.fillEmptyGrancChildPeriods();
}
if (this.hasFilledEmptyChildPeriods) {
return;
}
this.hasFilledEmptyChildPeriods = true;
const idRange = this.getChildrenRange();
if (!idRange) {
return;
}
let i = 0;
for (let newId = idRange[0]; newId <= idRange[1]; newId++) {
if (i < this.children.length) {
// if existing and new id match, skip, item already in place
// else
if (this.children[i].id !== newId) {
const empty = new PywbPeriod({type: this.type + 1, id: newId});
if (newId < this.children[i].id) {
// insert new before existing
this.children.splice(i, 0, empty);
} else {
// insert new after existing
this.children.splice(i+1, 0, empty);
}
// manually push children (no need to reverse link parent
//empty.parent = this;
}
i++;
} else {
const empty = new PywbPeriod({type: this.type + 1, id: newId});
this.children.push(empty);
// manually push children (no need to reverse link parent
//empty.parent = this;
}
}
// re-calculate indexes
for(let i=0;i<this.children.length;i++) {
this.childrenIds[this.children[i].id] = i;
}
return idRange;
};
PywbPeriod.prototype.getParents = function(skipAllTime=false) {
let parents = [];
let parent = this.parent;
while(parent) {
parents.push(parent);
parent = parent.parent;
}
parents = parents.reverse();
if (skipAllTime) {
parents.shift(); // skip first "all-time"
}
return parents;
};
PywbPeriod.prototype.contains = function(periodOrSnapshot) {
if (this.type === 0) {
return true; // all-time contains everything
}
if (periodOrSnapshot instanceof PywbPeriod) {
return periodOrSnapshot.getParents(true).slice(0,this.type).map(p => p.id).join(PywbPeriodIdDelimiter) === this.fullId;
}
if (periodOrSnapshot instanceof PywbSnapshot) {
if (this.type === PywbPeriod.Type.snapshot) {
return periodOrSnapshot.getFullId() === this.fullId;
} else {
return periodOrSnapshot.getParentIds(true).slice(0,this.type).join(PywbPeriodIdDelimiter) === this.fullId;
}
}
return false;
};
PywbPeriod.prototype.snapshot = null;
PywbPeriod.prototype.snapshotPeriod = null;
PywbPeriod.prototype.checkIfSingleSnapshotOnly = function() {
if (this.snapshotCount === 1) {
let snapshotPeriod = this;
let failSafe = PywbPeriod.Type.snapshot;
while(!snapshotPeriod.snapshot) {
if (--failSafe <=0) break;
snapshotPeriod = snapshotPeriod.children[0];
}
this.snapshot = snapshotPeriod.snapshot;
this.snapshotPeriod = snapshotPeriod;
}
};
PywbPeriod.prototype.setSnapshot = function(snap) {
this.snapshot = snap;
this.snapshotCount++;
let parent = this.parent;
let child = this;
while (parent) {
parent.snapshotCount++;
let grandParent = parent.parent;
if (grandParent) { // grandparent
grandParent.maxGrandchildSnapshotCount = Math.max(grandParent.maxGrandchildSnapshotCount, child.snapshotCount);
}
child = parent;
parent = parent.parent;
}
};
PywbPeriod.prototype.getSnapshotPeriodsFlat = function(flatArray=false) {
if (!flatArray) {
flatArray = [];
}
if (!this.snapshotCount) {
return flatArray;
}
if (this.snapshotCount === 1) {
flatArray.push(this.snapshotPeriod || this);
return flatArray;
}
this.children.forEach(child => {
child.getSnapshotPeriodsFlat(flatArray);
});
return flatArray;
};
/**
* Return the "full" id, which includes all parents ID and self ID, delimited by a ${PywbPeriodIdDelimiter}
* @returns {string}
*/
PywbPeriod.prototype.initFullId = function() {
const ids = this.getParents(true).map(p => p.id);
ids.push(this.id);
this.fullId = ids.join(PywbPeriodIdDelimiter);
};
/**
* Find a period by its full ID (of all ancestors and self, delimited by a hyphen). Start by locating the great-grand-parent (aka timeline), then looping on all IDs and finding the period in loop
* @param {string} fullId
* @returns {boolean}
*/
PywbPeriod.prototype.findByFullId = function(fullId) {
let parent = this;
if (this.type !== PywbPeriod.Type.all) {
parent = this.getParents()[0];
}
const ids = fullId.split(PywbPeriodIdDelimiter);
let found = false;
for(let i=0; i<ids.length; i++) {
parent = parent.getChildById(ids[i]);
if (parent) {
// if last chunk of ID in loop, the period is found
if (i === ids.length - 1) {
found = parent;
}
} else {
// if no parent is found with ID chunk, abort "mission"
break;
}
}
return found;
};
PywbPeriod.prototype.getFullReadableId = function(hasDayCardinalSuffix) {
// remove "all-time" from parents (getParents(true) when printing readable id (of all parents and currrent
switch (this.type) {
case PywbPeriod.Type.all:
return "";
case PywbPeriod.Type.year:
return this.id;
case PywbPeriod.Type.month:
return this.getReadableId(hasDayCardinalSuffix) + ' ' + this.parent.id;
case PywbPeriod.Type.day: {
return this.parent.getReadableId(hasDayCardinalSuffix) + ' ' + this.getReadableId(hasDayCardinalSuffix) + ', ' + this.parent.parent.id;
}
case PywbPeriod.Type.hour:
return this.parent.parent.getReadableId(hasDayCardinalSuffix) + ' ' + this.parent.getReadableId(hasDayCardinalSuffix) + ', ' + this.parent.parent.parent.id + ' at ' + this.getReadableId(hasDayCardinalSuffix);
case PywbPeriod.Type.snapshot:
return this.parent.parent.getReadableId(hasDayCardinalSuffix) + ' ' + this.parent.getReadableId(hasDayCardinalSuffix) + ', ' + this.parent.parent.parent.id + ' at ' + this.snapshot.getTimeFormatted();
}
};
PywbPeriod.prototype.getReadableId = function(hasDayCardinalSuffix) {
switch (this.type) {
case PywbPeriod.Type.all:
return "All-time";
case PywbPeriod.Type.year:
return this.id;
case PywbPeriod.Type.month:
return PywbMonthLabels[this.id];
case PywbPeriod.Type.day: {
let suffix = "";
if (hasDayCardinalSuffix) {
const singleDigit = this.id % 10;
const isTens = Math.floor(this.id / 10) === 1;
const suffixes = {1:"st", 2:"nd",3:"rd"};
suffix = (isTens || !suffixes[singleDigit]) ? "th" : suffixes[singleDigit];
}
return this.id + suffix;
}
case PywbPeriod.Type.hour:
return ({1:"12 am", 2: "3 am", 3: "6 am", 4: "9 am", 5: "noon", 6: "3 pm", 7: "6 pm", 8: "9 pm"})[this.id];
//return ({1:'midnight', 2: '6 am', 3: 'noon', 4: '6 pm'})[this.id];
//return (this.id < 13 ? this.id : this.id % 12) + ' ' + (this.id < 12 ? 'am':'pm');
case PywbPeriod.Type.snapshot:
return this.snapshot.getTimeFormatted();
}
};
PywbPeriod.prototype.getYear = function() { this.get(PywbPeriod.Type.year); };
PywbPeriod.prototype.getMonth = function() { this.get(PywbPeriod.Type.month); };
PywbPeriod.prototype.getDay = function() { this.get(PywbPeriod.Type.day); };
PywbPeriod.prototype.getHour = function() { this.get(PywbPeriod.Type.hour); };
PywbPeriod.prototype.get = function(type) {
if (this.type === type) {
return this;
} else if (this.type > type) {
return this.getParents()[type];
}
};

1810
pywb/vueui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -285,8 +285,8 @@ class LiveWebLoader(BaseLoader):
self.socks_proxy = None
def load_resource(self, cdx, params):
if cdx.get('filename') and cdx.get('offset') is not None:
return None
#if cdx.get('filename') and cdx.get('offset') is not None:
# return None
load_url = cdx.get('load_url')
if not load_url: