mirror of
https://github.com/flutter/flutter
synced 2024-09-13 13:21:36 +00:00
Add initial SkyElement & city-list example
BUG= R=eseidel@chromium.org Review URL: https://codereview.chromium.org/698653002
This commit is contained in:
parent
bcb841290f
commit
fe0d6c747f
5048
examples/city-list/city-data-service.sky
Normal file
5048
examples/city-list/city-data-service.sky
Normal file
File diff suppressed because it is too large
Load diff
595
examples/city-list/city-list.sky
Normal file
595
examples/city-list/city-list.sky
Normal file
|
@ -0,0 +1,595 @@
|
|||
<!--
|
||||
// Copyright 2014 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
-->
|
||||
<link rel="import"
|
||||
href="../../framework/sky-element/sky-element.sky"
|
||||
as="SkyElement" />
|
||||
<link rel="import" href="city-data-service.sky" as="CityDataService" />
|
||||
<link rel="import" href="city-sequence.sky" as="CitySequence" />
|
||||
|
||||
<template>
|
||||
<style>
|
||||
div {
|
||||
font-size: 16px;
|
||||
color: #FFF;
|
||||
background-color: #333;
|
||||
padding: 4px 4px 4px 12px;
|
||||
}
|
||||
</style>
|
||||
<div>{{ state }}</div>
|
||||
</template>
|
||||
<script>
|
||||
SkyElement({
|
||||
name: 'state-header',
|
||||
|
||||
set datum(datum) {
|
||||
this.state = datum.state;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<style>
|
||||
div {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding: 2px 4px 4px 12px;
|
||||
background-color: #DDD;
|
||||
}
|
||||
</style>
|
||||
<div>{{ letter }}</div>
|
||||
</template>
|
||||
<script>
|
||||
SkyElement({
|
||||
name: 'letter-header',
|
||||
|
||||
set datum(datum) {
|
||||
this.letter = datum.letter;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<style>
|
||||
:host {
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
padding: 8px 4px 4px 12px;
|
||||
border-bottom: 1px solid #EEE;
|
||||
line-height: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#name {
|
||||
font-weight: bold
|
||||
}
|
||||
|
||||
#population {
|
||||
color: #AAA;
|
||||
}
|
||||
|
||||
</style>
|
||||
<div>
|
||||
<span id="name">{{ name }}</span>,
|
||||
<span id="population">population {{ population }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
SkyElement({
|
||||
name: 'city-item',
|
||||
|
||||
set datum(datum) {
|
||||
this.name = datum.name;
|
||||
this.population = datum.population;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<style>
|
||||
|
||||
:host {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#scroller {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
perspective: 5px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#scroller::-webkit-scrollbar {
|
||||
display:none;
|
||||
}
|
||||
|
||||
#scrollarea {
|
||||
will-change: transform;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
#contentarea {
|
||||
position: absolute;
|
||||
will-change: contents;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.void {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.position {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div id="scroller" fit>
|
||||
<div id="scrollarea">
|
||||
<div id="contentarea">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script>
|
||||
|
||||
(function(global) {
|
||||
"use strict";
|
||||
|
||||
var LOAD_LENGTH = 20;
|
||||
var LOAD_BUFFER_PRE = LOAD_LENGTH * 4;
|
||||
var LOAD_BUFFER_POST = LOAD_LENGTH * 4;
|
||||
|
||||
function Loader() {
|
||||
this.loadingData = false;
|
||||
this.data = null;
|
||||
this.zeroIndex = 0;
|
||||
this.loadIndex = 0;
|
||||
}
|
||||
|
||||
Loader.prototype.localIndex = function(externalIndex) {
|
||||
return externalIndex + this.zeroIndex;
|
||||
}
|
||||
|
||||
Loader.prototype.externalIndex = function(localIndex) {
|
||||
return localIndex - this.zeroIndex;
|
||||
}
|
||||
|
||||
Loader.prototype.getItems = function() {
|
||||
return this.data ? this.data.items : [];
|
||||
}
|
||||
|
||||
Loader.prototype.maybeLoadMoreData = function(dataloadedCallback,
|
||||
firstVisible) {
|
||||
if (this.loadingData)
|
||||
return;
|
||||
|
||||
if (firstVisible) {
|
||||
this.loadIndex = this.externalIndex(
|
||||
this.data.items.indexOf(firstVisible));
|
||||
}
|
||||
|
||||
var localIndex = this.localIndex(this.loadIndex);
|
||||
var loadedPre = 0;
|
||||
var loadedPost = 0;
|
||||
|
||||
if (this.data) {
|
||||
loadedPre = localIndex;
|
||||
loadedPost = this.data.items.length - loadedPre;
|
||||
}
|
||||
|
||||
var loadTime;
|
||||
if (loadedPre >= LOAD_BUFFER_PRE && loadedPost >= LOAD_BUFFER_POST) {
|
||||
if (window.startLoad) {
|
||||
loadTime = new Date().getTime() - window.startLoad;
|
||||
console.log('Load: ' + loadTime + 'ms');
|
||||
window.startLoad = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadingData = true;
|
||||
|
||||
var loadIndex;
|
||||
|
||||
if (!this.data) {
|
||||
// Initial batch
|
||||
loadIndex = 0;
|
||||
} else if (loadedPost < LOAD_BUFFER_POST) {
|
||||
// Load forward first
|
||||
loadIndex = this.data.items.length;
|
||||
} else {
|
||||
// Then load backward
|
||||
loadIndex = -LOAD_LENGTH;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var externalIndex = this.externalIndex(loadIndex);
|
||||
|
||||
try {
|
||||
CityDataService.service.then(function(cityService) {
|
||||
return cityService.get(externalIndex, LOAD_LENGTH)
|
||||
.then(function(cities) {
|
||||
var indexOffset = 0;
|
||||
var newData = new CitySequence(cities);
|
||||
if (!self.data) {
|
||||
self.data = newData;
|
||||
} else if (loadIndex > 0) {
|
||||
self.data.append(newData);
|
||||
} else {
|
||||
self.zeroIndex += LOAD_LENGTH;
|
||||
indexOffset = LOAD_LENGTH;
|
||||
newData.append(self.data);
|
||||
self.data = newData;
|
||||
}
|
||||
|
||||
self.loadingData = false;
|
||||
dataloadedCallback(self.data, indexOffset);
|
||||
});
|
||||
}).catch(function(ex) {
|
||||
console.log(ex.stack);
|
||||
});
|
||||
} catch (ex) {
|
||||
console.log(ex.stack);
|
||||
}
|
||||
}
|
||||
|
||||
function Scroller() {
|
||||
this.contentarea = null;
|
||||
this.scroller = null;
|
||||
this.contentTop = 0; // #contentarea's current top
|
||||
this.scrollTop = 0; // #scrollarea's current top
|
||||
this.scrollHeight = -1; // height of #scroller (the viewport)
|
||||
this.lastScrollTop = 0; // last known scrollTop to compute deltas
|
||||
}
|
||||
|
||||
Scroller.prototype.setup = function(scroller, scrollarea, contentarea) {
|
||||
this.contentarea = contentarea;
|
||||
this.scroller = scroller;
|
||||
|
||||
this.scrollHeight = scroller.offsetHeight;
|
||||
scrollarea.style.height = (this.scrollHeight) * 4 + 'px';
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
Scroller.prototype.captureNewFrame = function(event) {
|
||||
var scrollTop = event.target.scrollTop;
|
||||
|
||||
// Protect from re-entry.
|
||||
if (this.lastScrollTop == scrollTop)
|
||||
return false;
|
||||
|
||||
var scrollDown = scrollTop > this.lastScrollTop;
|
||||
if (scrollDown) {
|
||||
while (scrollTop > this.scrollHeight * 1.5) {
|
||||
scrollTop -= this.scrollHeight;
|
||||
this.contentTop -= this.scrollHeight;
|
||||
}
|
||||
} else {
|
||||
while(scrollTop < this.scrollHeight * 1.5) {
|
||||
scrollTop += this.scrollHeight;
|
||||
this.contentTop += this.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastScrollTop = scrollTop;
|
||||
event.target.scrollTop = scrollTop;
|
||||
this.contentarea.style.top = this.contentTop + 'px';
|
||||
|
||||
this.scrollTop = scrollTop - this.contentTop;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Scroller.prototype.reset = function() {
|
||||
if (!this.contentarea)
|
||||
return;
|
||||
|
||||
this.scroller.scrollTop = this.scrollHeight;
|
||||
this.lastScrollTop = this.scrollHeight;
|
||||
|
||||
this.contentarea.style.top = this.scrollHeight + 'px';
|
||||
this.contentTop = this.scrollHeight;
|
||||
this.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Current position and height of the scroller, that could
|
||||
// be used (by Tiler, for example) to reason about where to
|
||||
// place visible things.
|
||||
Scroller.prototype.getCurrentFrame = function() {
|
||||
return { top: this.scrollTop, height: this.scrollHeight };
|
||||
}
|
||||
|
||||
Scroller.prototype.hasFrame = function() {
|
||||
return this.scrollHeight != -1;
|
||||
}
|
||||
|
||||
function Tile(datum, element, viewType, index) {
|
||||
this.datum = datum;
|
||||
this.element = element;
|
||||
this.viewType = viewType;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
function Tiler(contentArea, views, viewHeights) {
|
||||
this.contentArea = contentArea;
|
||||
this.drawTop = 0;
|
||||
this.drawBottom = 0;
|
||||
this.firstItem = -1;
|
||||
this.tiles = [];
|
||||
this.viewHeights = viewHeights;
|
||||
this.views = views;
|
||||
}
|
||||
|
||||
Tiler.prototype.setupViews = function(scrollFrame) {
|
||||
for (var type in this.viewHeights) {
|
||||
this.initializeViewType(scrollFrame, type, this.viewHeights[type]);
|
||||
}
|
||||
}
|
||||
|
||||
Tiler.prototype.initializeViewType = function(scrollFrame, viewType,
|
||||
height) {
|
||||
var count = Math.ceil(scrollFrame.height / height) * 2;
|
||||
var viewCache = this.views[viewType] = {
|
||||
indices: [],
|
||||
elements: []
|
||||
};
|
||||
|
||||
var protoElement;
|
||||
switch (viewType) {
|
||||
case 'stateHeader':
|
||||
protoElement = document.createElement('state-header');
|
||||
break;
|
||||
case 'letterHeader':
|
||||
protoElement = document.createElement('letter-header');
|
||||
break;
|
||||
case 'cityItem':
|
||||
protoElement = document.createElement('city-item');
|
||||
break;
|
||||
default:
|
||||
console.warn('Unknown viewType: ' + viewType);
|
||||
}
|
||||
protoElement.style.display = 'none';
|
||||
protoElement.style.height = height;
|
||||
protoElement.classList.add('position');
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
var clone = protoElement.cloneNode(false);
|
||||
this.contentArea.appendChild(clone);
|
||||
viewCache.elements.push(clone);
|
||||
viewCache.indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
Tiler.prototype.checkoutTile = function(viewType, datum, top) {
|
||||
var viewCache = this.views[viewType];
|
||||
var index = viewCache.indices.pop();
|
||||
var element = viewCache.elements[index];
|
||||
element.datum = datum;
|
||||
element.style.display = '';
|
||||
element.style.top = top + 'px';
|
||||
|
||||
return new Tile(datum, element, viewType, index);
|
||||
}
|
||||
|
||||
Tiler.prototype.checkinTile = function(tile) {
|
||||
if (!tile.element)
|
||||
return;
|
||||
|
||||
tile.element.style.display = 'none';
|
||||
this.views[tile.viewType].indices.push(tile.index);
|
||||
}
|
||||
|
||||
Tiler.prototype.getFirstVisibleDatum = function(scrollFrame) {
|
||||
var tiles = this.tiles;
|
||||
var viewHeights = this.viewHeights;
|
||||
|
||||
var itemTop = this.drawTop - scrollFrame.top;
|
||||
if (itemTop >= 0 && tiles.length)
|
||||
return tiles[0].datum;
|
||||
|
||||
var tile;
|
||||
for (var i = 0; i < tiles.length && itemTop < 0; i++) {
|
||||
tile = tiles[i];
|
||||
var height = viewHeights[tile.viewType];
|
||||
itemTop += height;
|
||||
}
|
||||
|
||||
return tile ? tile.datum : null;
|
||||
}
|
||||
|
||||
Tiler.prototype.viewType = function(datum) {
|
||||
switch (datum.headerOrder) {
|
||||
case 1: return 'stateHeader';
|
||||
case 2: return 'letterHeader';
|
||||
default: return 'cityItem';
|
||||
}
|
||||
}
|
||||
|
||||
Tiler.prototype.drawTiles = function(scrollFrame, data) {
|
||||
var tiles = this.tiles;
|
||||
var viewHeights = this.viewHeights;
|
||||
|
||||
var buffer = Math.round(scrollFrame.height / 2);
|
||||
var targetTop = scrollFrame.top - buffer;
|
||||
var targetBottom = scrollFrame.top + scrollFrame.height + buffer;
|
||||
|
||||
// Collect down to targetTop
|
||||
var first = tiles[0];
|
||||
while (tiles.length &&
|
||||
targetTop > this.drawTop + viewHeights[first.viewType]) {
|
||||
|
||||
var height = viewHeights[first.viewType];
|
||||
this.drawTop += height;
|
||||
|
||||
this.firstItem++;
|
||||
this.checkinTile(tiles.shift());
|
||||
|
||||
first = tiles[0];
|
||||
}
|
||||
|
||||
// Collect up to targetBottom
|
||||
var last = tiles[tiles.length - 1];
|
||||
while(tiles.length &&
|
||||
targetBottom < this.drawBottom - viewHeights[last.viewType]) {
|
||||
|
||||
var height = viewHeights[last.viewType];
|
||||
this.drawBottom -= height;
|
||||
|
||||
this.checkinTile(tiles.pop());
|
||||
|
||||
last = tiles[tiles.length - 1];
|
||||
}
|
||||
|
||||
// Layout up to targetTop
|
||||
while (this.firstItem > 0 &&
|
||||
targetTop < this.drawTop) {
|
||||
|
||||
var datum = data[this.firstItem - 1];
|
||||
var type = this.viewType(datum);
|
||||
var height = viewHeights[type];
|
||||
|
||||
this.drawTop -= height;
|
||||
|
||||
var tile = targetBottom < this.drawTop ?
|
||||
new Tile(datum, null, viewType, -1) : // off-screen
|
||||
this.checkoutTile(type, datum, this.drawTop);
|
||||
|
||||
this.firstItem--;
|
||||
tiles.unshift(tile);
|
||||
}
|
||||
|
||||
// Layout down to targetBottom
|
||||
while (this.firstItem + tiles.length < data.length - 1 &&
|
||||
targetBottom > this.drawBottom) {
|
||||
|
||||
var datum = data[this.firstItem + tiles.length];
|
||||
var type = this.viewType(datum);
|
||||
var height = viewHeights[type];
|
||||
|
||||
this.drawBottom += height;
|
||||
|
||||
var tile = targetTop > this.drawBottom ?
|
||||
new Tile(datum, null, viewType, -1) : // off-screen
|
||||
this.checkoutTile(type, datum, this.drawBottom - height);
|
||||
|
||||
tiles.push(tile);
|
||||
}
|
||||
|
||||
// Debug validate:
|
||||
// for (var i = 0; i < tiles.length; i++) {
|
||||
// if (tiles[i].datum !== data[this.firstItem + i])
|
||||
// throw Error('Invalid')
|
||||
// }
|
||||
}
|
||||
|
||||
// FIXME: Needs better name.
|
||||
Tiler.prototype.updateFirstItem = function(offset) {
|
||||
var tiles = this.tiles;
|
||||
|
||||
if (!tiles.length) {
|
||||
this.firstItem = 0;
|
||||
} else {
|
||||
this.firstItem += offset;
|
||||
}
|
||||
}
|
||||
|
||||
Tiler.prototype.checkinAllTiles = function() {
|
||||
var tiles = this.tiles;
|
||||
while (tiles.length) {
|
||||
this.checkinTile(tiles.pop());
|
||||
}
|
||||
}
|
||||
|
||||
Tiler.prototype.reset = function() {
|
||||
this.checkinAllTiles();
|
||||
this.drawTop = 0;
|
||||
this.drawBottom = 0;
|
||||
}
|
||||
|
||||
SkyElement({
|
||||
name: 'city-list',
|
||||
loader: null,
|
||||
scroller: null,
|
||||
tiler: null,
|
||||
date: null,
|
||||
month: null,
|
||||
views: null,
|
||||
|
||||
attached: function() {
|
||||
this.views = {};
|
||||
this.loader = new Loader();
|
||||
this.scroller = new Scroller();
|
||||
this.tiler = new Tiler(
|
||||
this.shadowRoot.getElementById('contentarea'), this.views, {
|
||||
stateHeader: 27,
|
||||
letterHeader: 18,
|
||||
cityItem: 30
|
||||
});
|
||||
|
||||
this.dataLoaded = this.dataLoaded.bind(this);
|
||||
this.shadowRoot.getElementById('scroller')
|
||||
.addEventListener('scroll', this.handleScroll.bind(this));
|
||||
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self.domReady();
|
||||
self.loader.maybeLoadMoreData(self.dataLoaded);
|
||||
});
|
||||
},
|
||||
|
||||
domReady: function() {
|
||||
this.scroller.setup(this.shadowRoot.getElementById('scroller'),
|
||||
this.shadowRoot.getElementById('scrollarea'),
|
||||
this.shadowRoot.getElementById('contentarea'));
|
||||
var scrollFrame = this.scroller.getCurrentFrame();
|
||||
this.tiler.setupViews(scrollFrame);
|
||||
},
|
||||
|
||||
updateView: function(data, scrollChanged) {
|
||||
var scrollFrame = this.scroller.getCurrentFrame();
|
||||
this.tiler.drawTiles(scrollFrame, data);
|
||||
var datum = scrollChanged ?
|
||||
this.tiler.getFirstVisibleDatum(scrollFrame) : null;
|
||||
this.loader.maybeLoadMoreData(this.dataLoaded, datum);
|
||||
},
|
||||
|
||||
dataLoaded: function(data, indexOffset) {
|
||||
var scrollFrame = this.scroller.getCurrentFrame();
|
||||
this.tiler.updateFirstItem(indexOffset);
|
||||
this.updateView(data.items, false);
|
||||
},
|
||||
|
||||
handleScroll: function(event) {
|
||||
if (!this.scroller.captureNewFrame(event))
|
||||
return;
|
||||
|
||||
this.updateView(this.loader.getItems(), true);
|
||||
}
|
||||
});
|
||||
|
||||
})(this);
|
||||
|
||||
</script>
|
||||
|
64
examples/city-list/city-sequence.sky
Normal file
64
examples/city-list/city-sequence.sky
Normal file
|
@ -0,0 +1,64 @@
|
|||
<!--
|
||||
// Copyright 2014 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
-->
|
||||
<script>
|
||||
function StateHeader(state) {
|
||||
this.state = state;
|
||||
this.headerOrder = 1;
|
||||
};
|
||||
|
||||
function LetterHeader(letter) {
|
||||
this.letter = letter;
|
||||
this.headerOrder = 2;
|
||||
}
|
||||
|
||||
function CitySequence(cities)
|
||||
{
|
||||
this.items = [];
|
||||
this.cursor = 0;
|
||||
|
||||
var lastState;
|
||||
var lastLetter;
|
||||
|
||||
for (var i = 0; i < cities.length; i++) {
|
||||
var city = cities[i];
|
||||
if (!lastState || lastState.state != city.state) {
|
||||
lastState = new StateHeader(city.state);
|
||||
this.items.push(lastState);
|
||||
lastLetter = undefined;
|
||||
}
|
||||
if (!lastLetter || lastLetter.letter != city.name[0]) {
|
||||
lastLetter = new LetterHeader(city.name[0]);
|
||||
this.items.push(lastLetter);
|
||||
}
|
||||
this.items.push(city);
|
||||
}
|
||||
};
|
||||
|
||||
CitySequence.prototype = {
|
||||
append: function(other) {
|
||||
var lastCity = this.items[this.items.length - 1];
|
||||
var firstOtherCity = other.items[2];
|
||||
|
||||
var index = 0;
|
||||
if (firstOtherCity.state == lastCity.state) {
|
||||
// skip StateHeader
|
||||
if (firstOtherCity.name[0] == lastCity.name[0]) {
|
||||
// skip LetterHeader
|
||||
index = 2;
|
||||
} else {
|
||||
index = 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (; index < other.items.length; index++) {
|
||||
this.items.push(other.items[index]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = CitySequence;
|
||||
|
||||
</script>
|
33
examples/city-list/index.sky
Normal file
33
examples/city-list/index.sky
Normal file
|
@ -0,0 +1,33 @@
|
|||
<!--
|
||||
// Copyright 2014 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport"
|
||||
content="initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=yes, width=device-width">
|
||||
<link rel="import" href="city-list.sky" />
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Roboto", "HelveticaNeue",sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: 13px;
|
||||
color: #222;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<script>
|
||||
window.startLoad = new Date().getTime();
|
||||
</script>
|
||||
<body>
|
||||
<city-list></city-list>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue