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:
parent
1dedc46dce
commit
028e7102c0
@ -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
|
||||
|
@ -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'):
|
||||
|
@ -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):
|
||||
|
BIN
pywb/static/calendar-icon.png
Normal file
BIN
pywb/static/calendar-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
@ -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
|
||||
|
BIN
pywb/static/pywb-logo-sm.png
Normal file
BIN
pywb/static/pywb-logo-sm.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
BIN
pywb/static/timeline-icon.png
Normal file
BIN
pywb/static/timeline-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
14653
pywb/static/vue/vueui.js
Normal file
14653
pywb/static/vue/vueui.js
Normal file
File diff suppressed because one or more lines are too long
11
pywb/static/vue_banner.css
Normal file
11
pywb/static/vue_banner.css
Normal file
@ -0,0 +1,11 @@
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#wb_iframe_div, #replay_iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
BIN
pywb/static/zoom-out-icon-333316.png
Normal file
BIN
pywb/static/zoom-out-icon-333316.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
@ -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 %}
|
||||
|
@ -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
39
pywb/vueui/.eslintrc.js
Normal 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
21
pywb/vueui/package.json
Normal 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"
|
||||
}
|
||||
}
|
20
pywb/vueui/rollup.config.js
Normal file
20
pywb/vueui/rollup.config.js
Normal 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
245
pywb/vueui/src/App.vue
Normal 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="' '"></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>
|
173
pywb/vueui/src/components/CalendarMonth.vue
Normal file
173
pywb/vueui/src/components/CalendarMonth.vue
Normal 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="' '"></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>
|
||||
|
140
pywb/vueui/src/components/CalendarYear.vue
Normal file
140
pywb/vueui/src/components/CalendarYear.vue
Normal 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>
|
||||
|
151
pywb/vueui/src/components/PageRuler.vue
Normal file
151
pywb/vueui/src/components/PageRuler.vue
Normal 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>
|
431
pywb/vueui/src/components/Timeline.vue
Normal file
431
pywb/vueui/src/components/Timeline.vue
Normal 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="'◀'" 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="'▶'" 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>
|
83
pywb/vueui/src/components/TimelineBreadcrumbs.vue
Normal file
83
pywb/vueui/src/components/TimelineBreadcrumbs.vue
Normal 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>
|
||||
>
|
||||
<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>
|
||||
>
|
||||
</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>
|
72
pywb/vueui/src/components/TimelineLinear.vue
Normal file
72
pywb/vueui/src/components/TimelineLinear.vue
Normal 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>
|
58
pywb/vueui/src/components/Tooltip.vue
Normal file
58
pywb/vueui/src/components/Tooltip.vue
Normal 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
201
pywb/vueui/src/index.js
Normal 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
491
pywb/vueui/src/model.js
Normal 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
1810
pywb/vueui/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user