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

initial pass on integrating vue 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
This commit is contained in:
Ilya Kreymer 2021-08-31 21:17:01 -07:00
parent 96de80f83e
commit f8a73c0b03
18 changed files with 16873 additions and 10 deletions

View File

@ -8,6 +8,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,91 @@
// ===========================================================================
class CDXLoader {
constructor(staticPrefix, url, prefix, timestamp) {
this.opts = {};
this.prefix = prefix;
this.isReplay = (timestamp !== undefined);
if (this.isReplay) {
window.WBBanner = new VueBannerWrapper(this);
}
this.queryWorker = new Worker(staticPrefix + '/queryWorker.js');
// query form *?=url...
if (window.location.href.indexOf("*?") > 0) {
this.queryURL = window.location.href.replace("*?", "cdx?") + "&output=json";
url = new URL(this.queryURL).searchParams.get("url");
// otherwise, traditional calendar form /*/<url>
} else if (url) {
const params = new URLSearchParams();
params.set("url", url);
params.set("output", "json");
this.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";
const cdxList = [];
this.queryWorker.addEventListener("message", (event) => {
const data = event.data;
switch (data.type) {
case "cdxRecord":
cdxList.push(data.record);
break;
case "finished":
PywbVue.init(cdxList, this.opts, (snapshot) => this.loadSnapshot(snapshot));
break;
}
});
}
init() {
this.queryWorker.postMessage({
type: 'query',
queryURL: this.queryURL
});
}
loadSnapshot(snapshot) {
if (!isReplay) {
window.location.href = this.prefix + snapshot.id + "/" + snapshot.url;
}
}
}
// ===========================================================================
class VueBannerWrapper
{
constructor() {
this.loading = true;
this.lastUrl = null;
}
init() {}
stillIndicatesLoading() {
return this.loading;
}
updateCaptureInfo(url, ts, is_live) {
this.loading = false;
}
onMessage(event) {
console.log(event);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

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

File diff suppressed because one or more lines are too long

View File

@ -21,9 +21,28 @@ window.banner_info = {
};
</script>
<link rel='stylesheet' href='{{ static_prefix }}/default_banner.css'/>
{% if is_framed or old_banner %}
<!-- default banner, create through js -->
<script src='{{ static_prefix }}/default_banner.js'> </script>
<link rel='stylesheet' href='{{ static_prefix }}/default_banner.css'/>
{% else %}
<script src="{{ static_prefix }}/vue/vueui.js"></script>
<script src="{{ static_prefix }}/js/cdxquery.js"></script>
<script>
var loader = new CDXLoader("{{ static_prefix }}", "{{ url }}", "{{ wb_prefix }}", "{{ timestamp }}");
loader.init();
</script>
{% if not old_banner %}
<div id="app" style="width: 100%; height: 200px"></div>
{% endif %}
{% endif %}
{% endautoescape %}
{% endif %}

View File

@ -6,22 +6,24 @@
{% block head %}
{{ super() }}
{% if table_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>
<script src="{{ static_prefix }}/js/cdxquery.js"></script>
{% endif %}
{% endblock %}
{% block body %}
<div class="container-fluid">
<div class="row justify-content-center">
<h4 class="display-4 text-center text-sm-left p-0">{{ _('Search Results') }}</h4>
</div>
</div>
<div class="container">
<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>
<script>
var text = {
months: {
@ -46,7 +48,20 @@
dateTime: "{{ _('Date Time: ') }}",
};
{% if table_ui %}
var renderCal = new RenderCalendar({ prefix: "{{ prefix }}", staticPrefix: "{{ static_prefix }}", text: text });
renderCal.init();
{% else %}
var loader = new CDXLoader("{{ static_prefix }}", "{{ url }}", "{{ prefix }}");
loader.init();
{% endif %}
</script>
{% if not table_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: "PywbVue",
format: "iife",
},
plugins: [
vue({css: true, compileTemplate: true}),
css(),
nodeResolve({browser: true})
],
},
];

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

@ -0,0 +1,172 @@
<template>
<div class="app" data-app="webrecorder-replay-app">
<div class="short-nav">
<div class="first-line">
<div class="logo"><img :src="config.logoImg" /></div>
<div class="url-and-timeline">
<div class="url">
<strong>{{config.title}}</strong> ({{snapshots.length}} captures: {{snapshots[0].getDateFormatted()}} - {{snapshots[snapshots.length-1].getDateFormatted()}})<br/>
<TimelineSummary
v-if="currentPeriod"
:period="currentPeriod"
@goto-period="gotoPeriod"
></TimelineSummary>
<span class="full-view-toggle" :class="{expanded: showFullView}" @click="showFullView = !showFullView">
<template v-if="!showFullView"><span class="detail">show year calendar</span></template><template v-else><span class="detail">hide year calendar</span></template>
<img src="/static/calendar-icon.png" />
</span>
</div>
<Timeline
v-if="currentPeriod"
:period="currentPeriod"
:highlight="timelineHighlight"
@goto-period="gotoPeriod"
></Timeline>
</div>
</div>
<div class="second-line" v-if="currentSnapshot && !showFullView">
<h2>Showing Capture from {{currentSnapshot.getTimeDateFormatted()}}</h2>
</div>
</div>
<CalendarYear v-if="showFullView"
:period="currentPeriod"
@goto-period="gotoPeriod">
</CalendarYear>
</div>
</template>
<script>
import Timeline from "./components/Timeline.vue";
import TimelineSummary from "./components/Summary.vue";
import CalendarYear from "./components/CalendarYear.vue";
import { PywbSnapshot } from "./model.js";
export default {
name: "PywbReplayApp",
//el: '[data-app="webrecorder-replay-app"]',
data: function() {
return {
snapshots: [],
currentPeriod: null,
currentSnapshot: null,
msgs: [],
showFullView: false,
config: {
title: "",
initialView: {}
},
timelineHighlight: false
};
},
components: {Timeline, TimelineSummary, CalendarYear},
mounted: function() {
this.init();
},
methods: {
gotoPeriod: function(newPeriod, initiator) {
if (this.timelineHighlight) {
setTimeout((() => {
this.timelineHighlight=false;
}).bind(this), 3000);
}
if (newPeriod.snapshot) {
this.gotoSnapshot(newPeriod.snapshot);
} else {
this.currentPeriod = newPeriod;
}
},
gotoSnapshot(snapshot) {
this.currentSnapshot = snapshot;
// COMMUNICATE TO ContentFrame
this.$emit("show-snapshot", snapshot);
this.showFullView = false;
},
init() {
if (!this.config.title) {
this.config.title = this.config.initialView.url;
}
if (this.config.initialView.timestamp === "undefined") {
this.showFullView = true;
} else {
this.showFullView = false;
// convert to snapshot objec to support proper rendering of time/date
const snapshot = new PywbSnapshot(this.config.initialView, 0);
this.gotoSnapshot(snapshot);
}
}
}
};
</script>
<style>
.app {
border-bottom: 1px solid lightcoral;
height: 150px;
width: 100%;
}
.iframe iframe {
width: 100%;
height: 80vh;
}
.logo {
margin-right: 30px;
width: 180px;
}
.short-nav {
width: 100%;
position: relative;
}
.short-nav .first-line {
display: flex;
justify-content: flex-start;
}
.short-nav .second-line {
display: flex;
justify-content: flex-start;
}
.short-nav .logo {
flex-shrink: initial;
}
.short-nav .url-and-timeline {
flex-grow: 2;
overflow-x: hidden;
}
.short-nav .url {
text-align: left;
margin: 0 25px;
position: relative;
}
.full-view-toggle {
position: absolute;
top: 0;
right: 0;
border-radius: 5px;
padding: 4px;
cursor: zoom-in;
}
.full-view-toggle img {
width: 17px;
display: inline-block;
margin-top: 2px;
}
.full-view-toggle:hover {
background-color: #eeeeee;
}
.full-view-toggle .detail {
display: none;
}
.full-view-toggle:hover .detail {
display: inline;
}
.full-view-toggle.expanded {
background-color: #eeeeee;
cursor: zoom-out;
}
</style>

View File

@ -0,0 +1,151 @@
<style>
.calendar-month {
position: relative;
display: inline-block;
padding: 5px;
margin: 0;
height: 240px;
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 > 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 .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;
}
</style>
<template>
<div class="calendar-month" :class="{current: isCurrent}">
<h3>{{month.getReadableId()}} ({{month.snapshotCount}})</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}" :style="dayStyle" @click="gotoDay(day)"><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>&nbsp;</template></span></span>
</div>
<div v-else class="empty">no captures</div>
</div>
</template>
<script>
export default {
props: ["month", "year", "isCurrent"],
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;
}
},
methods: {
gotoDay(day) {
// upon doing to day, tell timeline to highlight itself
this.$root.timelineHighlight = true;
this.$emit("goto-period", day);
},
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;`;
}
}
};
</script>

View File

@ -0,0 +1,77 @@
<style>
.full-view {
position: absolute;
top: 130px;
left: 0;
height: 80vh;
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.snapshotCount}} captures in {{year.id}}</h2>
<div class="months">
<CalendarMonth
v-for="month in year.children"
:key="month.id"
:month="month"
:year="year"
:is-current="month === currentMonth"
@goto-period="$emit('goto-period', $event)"
></CalendarMonth>
</div>
</div>
</template>
<script>
import CalendarMonth from "./CalendarMonth.vue";
import { PywbPeriod } from "../model.js";
export default {
components: {CalendarMonth},
props: ["period"],
data: function() {
return {};
},
computed: {
year() {
let year = null;
if (this.period.type === PywbPeriod.Type.all) {
year = this.period.children[this.period.children.length-1];
} 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() {
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;
}
}
};
</script>

View File

@ -0,0 +1,76 @@
<template>
<div class="summary">
<template v-if="parents.length">
<span class="item">
<span class="goto" @click="changePeriod(parents[0])">{{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)">{{parent.getReadableId(true)}}</span>
</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: {
changePeriod(period) {
if (period.snapshotCount) {
this.$emit("goto-period", period);
}
},
}
};
</script>
<style>
.summary {
display: inline-block;
}
.summary .item {
position: relative;
display: inline;
margin: 0 2px 0 0;
font-size: inherit;
}
.summary .count {
vertical-align: middle;
font-size: inherit;
}
.summary .count .verbose {
display: none;
}
.summary .count:hover .verbose {
display: inline;
}
.summary .item .goto {
margin: 1px;
cursor: pointer;
color: darkslateblue;
text-decoration: underline;
}
.summary .item .goto:hover {
background-color: #a6cdf5;
}
.summary .item.snapshot {
display: block;
}
</style>

View File

@ -0,0 +1,379 @@
<template>
<div class="timeline">
<div class="arrow previous" :class="{disabled: isScrollZero && !previousPeriod}" @click="scrollPrev" @dblclick.stop.prevent>&#x25C0;</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': isLastZoomLevel}"
v-on:click="changePeriod(subPeriod)">
<div class="histo">
<div class="count" v-if="!subPeriod.snapshot && !isLastZoomLevel"><span class="count-inner">{{subPeriod.snapshotCount}}</span></div>
<div class="line"
v-for="histoPeriod in subPeriod.children"
:key="histoPeriod.id"
:style="{height: getHistoLineHeight(histoPeriod.snapshotCount)}"
v-on:click="changePeriodFromHistogram(histoPeriod)"
>
<div class="snap-info" v-if="isLastZoomLevel">{{histoPeriod.snapshot.getTimeDateFormatted()}}</div>
</div>
</div>
<div class="inner">
<div class="label">{{subPeriod.getReadableId()}}</div>
</div>
</div>
</div>
</div>
<div class="arrow next" :class="{disabled: isScrollMax && !nextPeriod}" @click="scrollNext" @dblclick.stop.prevent>&#x25B6;</div>
</div>
</template>
<script>
import { PywbPeriod } from "../model.js";
export default{
props: ["period", "highlight"],
data: function() {
return {
// TODO: remove widths (now using flex css for width calculation)
subPeriodBoxWidths: {// in pixels
[PywbPeriod.Type.year]: 80, // year box
[PywbPeriod.Type.month]: 80, // month box in year
[PywbPeriod.Type.day]: 20, // days box in month
[PywbPeriod.Type.hour]: 60, // hour box in day
[PywbPeriod.Type.snapshot]: 10 // snapshot box in hour/day
},
// TODO: remove widths (now using flex css for width calculation)
emptySubPeriodBoxWidths: {// in pixels
[PywbPeriod.Type.year]: 40, // year box
[PywbPeriod.Type.month]: 40, // month box in year
[PywbPeriod.Type.day]: 20, // days box in month
[PywbPeriod.Type.hour]: 40, // hour box in day
[PywbPeriod.Type.snapshot]: 20 // snapshot box in hour/day
},
highlightPeriod: null,
previousPeriod: null,
nextPeriod: null,
isScrollZero: true,
isScrollMax: true,
};
},
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);
// TODO: remove widths (now using flex css for width calculation), so we don't need manual calc
//this.adjustScrollElWidth();
this.$refs.periodScroll.addEventListener("scroll", this.updateScrollArrows);
window.addEventListener("resize", this.updateScrollArrows);
this.updateScrollArrows();
},
computed: {
subPeriodBoxWidth: function() {
return this.subPeriodBoxWidths[this.period.type+1]; // the type of the period children
},
// TODO: remove widths (now using flex css for width calculation)
emptySubPeriodBoxWidth: function() {
return this.emptySubPeriodBoxWidths[this.period.type+1]; // the type of the period children
},
// this determins which the last zoom level is before we go straight to showing snapshot
isLastZoomLevel() {
return this.period.type === PywbPeriod.Type.day;
}
},
updated() {
// do something on update
},
methods: {
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);
}
} else {
this.$refs.periodScroll.scrollLeft += 30;
}
},
scrollPrev: function () {
if (this.isScrollZero) {
if (this.previousPeriod) {
this.$emit("goto-period", this.previousPeriod);
}
} 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) {
if (period.snapshotCount) {
if (this.isLastZoomLevel) {
if (period.type === PywbPeriod.Type.snapshot) {
this.$emit("goto-period", period);
}
} else {
this.$emit("goto-period", period);
}
}
},
// special "change period" from histogram lines
changePeriodFromHistogram(period) {
if (this.isLastZoomLevel && period.type === PywbPeriod.Type.snapshot) {
this.$emit("goto-period", period);
}
},
onPeriodChanged(newPeriod, oldPeriod) {
this.addEmptySubPeriods();
this.previousPeriod = this.period.getPrevious();
this.nextPeriod = this.period.getNext();
// detect if going up level of period (new period type should be in old period parents)
if (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() {
// TODO: remove widths (now using flex css for width calculation), so we don't need manual calc
//this.adjustScrollElWidth();
this.restoreScroll();
this.updateScrollArrows();
}).bind(this), 1);
},
// TODO: remove widths (now using flex css for width calculation), so we don't need manual calc
adjustScrollElWidth() {
//this.$refs.periodScroll.style.maxWidth = this.$refs.periods._computedStyle.width;
}
}
};
</script>
<style>
.timeline {
position: relative;
display: flex;
width: auto;
height: 80px;
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: 80px;
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;
}
.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: #eee;*/
/*border-right: 1px solid white; !* all other periods have right border, except first period*!*/
cursor: pointer;
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;
}
/* empty period */
.timeline .period.empty {
color: #aaa;
/*background-color: transparent;*/
}
/* highlighted period */
.timeline .period.highlight {
background-color: cyan;
}
.timeline .period .label {
font-weight: bold;
font-size: 14px;
font-family: Baskerville, sans-serif;
}
.timeline .period .count {
position: absolute;
top: 0;
left: 0;
display: none;
width: 100%;
height: 100%;
padding: 10px 0 0 0;
text-align: center;
font-size: 25px;
}
.timeline .period .count .count-inner {
display: inline;
width: auto;
background-color: rgba(255,255,255,.85);
padding: 1px;
border-radius: 5px;
}
.timeline .period:hover .count {
display: block;
}
.timeline .period .inner {
display: block;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 16px;
background-color: white;
border-top: 1px solid gray;
}
.timeline .period.last-level .inner {
text-align: left;
}
.timeline .period .histo {
display: flex;
position: absolute;
top: 1px;
left: 0;
width: 100%;
height: 60px;
align-items: flex-end;
justify-content: space-between;
text-align: left;
}
.timeline .period .histo .line {
flex-grow: 1;
display: inline-block;
background-color: #a6cdf5;
margin: 0;
padding: 0;
}
/* 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;
position: relative;
}
/* update line color on hover*/
.timeline .period.last-level .histo .line:hover {
background-color: #f5a6eb;
}
/*Last level period histogram line has extra info*/
.timeline .period.last-level .histo .line .snap-info {
position: absolute;
z-index: 10;
top: 30%;
left: 120%;
display: none;
background-color: white;
border: 1px solid gray;
padding: 2px;
white-space: nowrap; /*no wrapping allowed*/
}
/*show on hover*/
.timeline .period.last-level .histo .line:hover .snap-info {
display: block;
}
</style>

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

@ -0,0 +1,23 @@
import appData from "./App.vue";
import { PywbData } from "./model.js";
import Vue from "vue/dist/vue.esm.browser";
export function init(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);
//console.log({...app.config, ...config}.logoImg);
app.$set(app, "config", {...app.config, ...config});
app.$mount("#app");
if (loadCallback) {
app.$on("show-snapshot", loadCallback);
}
}

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

@ -0,0 +1,360 @@
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"};
export function PywbData(rawSnaps) {
const allTimePeriod = new PywbPeriod({type: PywbPeriod.Type.all, id: "all"});
const snapshots = [];
let lastSingle = null;
rawSnaps.forEach((rawSnap, i) => {
const snap = new PywbSnapshot(rawSnap, i);
let year, month, day, hour, single;
if (!(year = allTimePeriod.getChildById(snap.year))) {
year = new PywbPeriod({type: PywbPeriod.Type.year, id: snap.year});
allTimePeriod.addChild(year);
}
if (!(month = year.getChildById(snap.month))) {
month = new PywbPeriod({type: PywbPeriod.Type.month, id: snap.month});
year.addChild(month);
}
if (!(day = month.getChildById(snap.day))) {
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)
//const hourValue = snap.hour;
if (!(hour = day.getChildById(hourValue))) {
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 `${this.year}-${PywbMonthLabels[this.month]}-${this.day} ${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");
}
}
/* ---------------- PERIOD object ----------------- */
export function PywbPeriod(init) {
this.type = init.type;
this.id = init.id;
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);
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.snapshot = null;
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.getFullReadableId = function(hasDayCardinalSuffix) {
// remove "all-time" from parents (getParents(true) when printing readable id (of all parents and currrent
return this.getParents(true).map(p => p.getReadableId(hasDayCardinalSuffix)).join(" ") + " " + this.getReadableId(hasDayCardinalSuffix);
};
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();
}
};

1810
pywb/vueui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff