init chapter working
@@ -14,6 +14,8 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lib-macros = { path = "../../libs/lib-macros" }
|
||||
lib-core = { path = "../../libs/lib-core" }
|
||||
|
||||
maud = { version = "0.26.0", features = ["axum"] }
|
||||
rust-embed = { version = "8.5.0", features = ["mime-guess", "mime_guess"] }
|
||||
time = { version = "0.3.36", features = ["formatting"] }
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
|
||||
|
||||
function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
|
||||
|
||||
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
|
||||
|
||||
import VirtualList from '../VirtualList';
|
||||
|
||||
var InfiniteVirtualList = function (_VirtualList) {
|
||||
_inherits(InfiniteVirtualList, _VirtualList);
|
||||
|
||||
function InfiniteVirtualList() {
|
||||
_classCallCheck(this, InfiniteVirtualList);
|
||||
|
||||
return _possibleConstructorReturn(this, _VirtualList.apply(this, arguments));
|
||||
}
|
||||
|
||||
InfiniteVirtualList.prototype.onRowsRendered = function onRowsRendered(_ref) {
|
||||
var _this2 = this;
|
||||
|
||||
var startIndex = _ref.startIndex,
|
||||
stopIndex = _ref.stopIndex;
|
||||
var _options = this.options,
|
||||
isRowLoaded = _options.isRowLoaded,
|
||||
loadMoreRows = _options.loadMoreRows,
|
||||
_options$minimumBatch = _options.minimumBatchSize,
|
||||
minimumBatchSize = _options$minimumBatch === undefined ? 10 : _options$minimumBatch,
|
||||
_options$rowCount = _options.rowCount,
|
||||
rowCount = _options$rowCount === undefined ? 0 : _options$rowCount,
|
||||
_options$threshold = _options.threshold,
|
||||
threshold = _options$threshold === undefined ? 15 : _options$threshold;
|
||||
|
||||
|
||||
var unloadedRanges = getUnloadedRanges({
|
||||
isRowLoaded: isRowLoaded,
|
||||
minimumBatchSize: minimumBatchSize,
|
||||
rowCount: rowCount,
|
||||
startIndex: Math.max(0, startIndex - threshold),
|
||||
stopIndex: Math.min(rowCount - 1, stopIndex + threshold)
|
||||
});
|
||||
|
||||
unloadedRanges.forEach(function (unloadedRange) {
|
||||
var promise = loadMoreRows(unloadedRange);
|
||||
|
||||
if (promise) {
|
||||
promise.then(function () {
|
||||
// Refresh the visible rows if any of them have just been loaded.
|
||||
// Otherwise they will remain in their unloaded visual state.
|
||||
if (isRangeVisible({
|
||||
lastRenderedStartIndex: startIndex,
|
||||
lastRenderedStopIndex: stopIndex,
|
||||
startIndex: unloadedRange.startIndex,
|
||||
stopIndex: unloadedRange.stopIndex
|
||||
})) {
|
||||
// Force update
|
||||
_this2.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return InfiniteVirtualList;
|
||||
}(VirtualList);
|
||||
|
||||
/**
|
||||
* Determines if the specified start/stop range is visible based on the most recently rendered range.
|
||||
*/
|
||||
|
||||
|
||||
export { InfiniteVirtualList as default };
|
||||
export function isRangeVisible(_ref2) {
|
||||
var lastRenderedStartIndex = _ref2.lastRenderedStartIndex,
|
||||
lastRenderedStopIndex = _ref2.lastRenderedStopIndex,
|
||||
startIndex = _ref2.startIndex,
|
||||
stopIndex = _ref2.stopIndex;
|
||||
|
||||
return !(startIndex > lastRenderedStopIndex || stopIndex < lastRenderedStartIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all of the ranges within a larger range that contain unloaded rows.
|
||||
*/
|
||||
export function getUnloadedRanges(_ref3) {
|
||||
var isRowLoaded = _ref3.isRowLoaded,
|
||||
minimumBatchSize = _ref3.minimumBatchSize,
|
||||
rowCount = _ref3.rowCount,
|
||||
startIndex = _ref3.startIndex,
|
||||
stopIndex = _ref3.stopIndex;
|
||||
|
||||
var unloadedRanges = [];
|
||||
var rangeStartIndex = null;
|
||||
var rangeStopIndex = null;
|
||||
|
||||
for (var index = startIndex; index <= stopIndex; index++) {
|
||||
var loaded = isRowLoaded(index);
|
||||
|
||||
if (!loaded) {
|
||||
rangeStopIndex = index;
|
||||
if (rangeStartIndex === null) {
|
||||
rangeStartIndex = index;
|
||||
}
|
||||
} else if (rangeStopIndex !== null) {
|
||||
unloadedRanges.push({
|
||||
startIndex: rangeStartIndex,
|
||||
stopIndex: rangeStopIndex
|
||||
});
|
||||
|
||||
rangeStartIndex = rangeStopIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If :rangeStopIndex is not null it means we haven't ran out of unloaded rows.
|
||||
// Scan forward to try filling our :minimumBatchSize.
|
||||
if (rangeStopIndex !== null) {
|
||||
var potentialStopIndex = Math.min(Math.max(rangeStopIndex, rangeStartIndex + minimumBatchSize - 1), rowCount - 1);
|
||||
|
||||
for (var _index = rangeStopIndex + 1; _index <= potentialStopIndex; _index++) {
|
||||
if (!isRowLoaded({ index: _index })) {
|
||||
rangeStopIndex = _index;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
unloadedRanges.push({
|
||||
startIndex: rangeStartIndex,
|
||||
stopIndex: rangeStopIndex
|
||||
});
|
||||
}
|
||||
|
||||
// Check to see if our first range ended prematurely.
|
||||
// In this case we should scan backwards to try filling our :minimumBatchSize.
|
||||
if (unloadedRanges.length) {
|
||||
var firstUnloadedRange = unloadedRanges[0];
|
||||
|
||||
while (firstUnloadedRange.stopIndex - firstUnloadedRange.startIndex + 1 < minimumBatchSize && firstUnloadedRange.startIndex > 0) {
|
||||
var _index2 = firstUnloadedRange.startIndex - 1;
|
||||
|
||||
if (!isRowLoaded({ index: _index2 })) {
|
||||
firstUnloadedRange.startIndex = _index2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unloadedRanges;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
|
||||
|
||||
/* Forked from react-virtualized 💖 */
|
||||
export var ALIGN_START = 'start';
|
||||
export var ALIGN_CENTER = 'center';
|
||||
export var ALIGN_END = 'end';
|
||||
|
||||
var SizeAndPositionManager = function () {
|
||||
function SizeAndPositionManager(_ref) {
|
||||
var itemCount = _ref.itemCount,
|
||||
itemSizeGetter = _ref.itemSizeGetter,
|
||||
estimatedItemSize = _ref.estimatedItemSize;
|
||||
|
||||
_classCallCheck(this, SizeAndPositionManager);
|
||||
|
||||
this._itemSizeGetter = itemSizeGetter;
|
||||
this._itemCount = itemCount;
|
||||
this._estimatedItemSize = estimatedItemSize;
|
||||
|
||||
// Cache of size and position data for items, mapped by item index.
|
||||
this._itemSizeAndPositionData = {};
|
||||
|
||||
// Measurements for items up to this index can be trusted; items afterward should be estimated.
|
||||
this._lastMeasuredIndex = -1;
|
||||
}
|
||||
|
||||
SizeAndPositionManager.prototype.getLastMeasuredIndex = function getLastMeasuredIndex() {
|
||||
return this._lastMeasuredIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns the size and position for the item at the specified index.
|
||||
* It just-in-time calculates (or used cached values) for items leading up to the index.
|
||||
*/
|
||||
|
||||
|
||||
SizeAndPositionManager.prototype.getSizeAndPositionForIndex = function getSizeAndPositionForIndex(index) {
|
||||
if (index < 0 || index >= this._itemCount) {
|
||||
throw Error('Requested index ' + index + ' is outside of range 0..' + this._itemCount);
|
||||
}
|
||||
|
||||
if (index > this._lastMeasuredIndex) {
|
||||
var lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
|
||||
var offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;
|
||||
|
||||
for (var i = this._lastMeasuredIndex + 1; i <= index; i++) {
|
||||
var size = this._itemSizeGetter({ index: i });
|
||||
|
||||
if (size == null || isNaN(size)) {
|
||||
throw Error('Invalid size returned for index ' + i + ' of value ' + size);
|
||||
}
|
||||
|
||||
this._itemSizeAndPositionData[i] = {
|
||||
offset: offset,
|
||||
size: size
|
||||
};
|
||||
|
||||
offset += size;
|
||||
}
|
||||
|
||||
this._lastMeasuredIndex = index;
|
||||
}
|
||||
|
||||
return this._itemSizeAndPositionData[index];
|
||||
};
|
||||
|
||||
SizeAndPositionManager.prototype.getSizeAndPositionOfLastMeasuredItem = function getSizeAndPositionOfLastMeasuredItem() {
|
||||
return this._lastMeasuredIndex >= 0 ? this._itemSizeAndPositionData[this._lastMeasuredIndex] : { offset: 0, size: 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Total size of all items being measured.
|
||||
* This value will be completedly estimated initially.
|
||||
* As items as measured the estimate will be updated.
|
||||
*/
|
||||
|
||||
|
||||
SizeAndPositionManager.prototype.getTotalSize = function getTotalSize() {
|
||||
var lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
|
||||
|
||||
return lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size + (this._itemCount - this._lastMeasuredIndex - 1) * this._estimatedItemSize;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines a new offset that ensures a certain item is visible, given the alignment.
|
||||
*
|
||||
* @param align Desired alignment within container; one of "start" (default), "center", or "end"
|
||||
* @param containerSize Size (width or height) of the container viewport
|
||||
* @return Offset to use to ensure the specified item is visible
|
||||
*/
|
||||
|
||||
|
||||
SizeAndPositionManager.prototype.getUpdatedOffsetForIndex = function getUpdatedOffsetForIndex(_ref2) {
|
||||
var _ref2$align = _ref2.align,
|
||||
align = _ref2$align === undefined ? ALIGN_START : _ref2$align,
|
||||
containerSize = _ref2.containerSize,
|
||||
targetIndex = _ref2.targetIndex;
|
||||
|
||||
if (containerSize <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var datum = this.getSizeAndPositionForIndex(targetIndex);
|
||||
var maxOffset = datum.offset;
|
||||
var minOffset = maxOffset - containerSize + datum.size;
|
||||
|
||||
var idealOffset = void 0;
|
||||
|
||||
switch (align) {
|
||||
case ALIGN_END:
|
||||
idealOffset = minOffset;
|
||||
break;
|
||||
case ALIGN_CENTER:
|
||||
idealOffset = maxOffset - (containerSize - datum.size) / 2;
|
||||
break;
|
||||
default:
|
||||
idealOffset = maxOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
var totalSize = this.getTotalSize();
|
||||
|
||||
return Math.max(0, Math.min(totalSize - containerSize, idealOffset));
|
||||
};
|
||||
|
||||
SizeAndPositionManager.prototype.getVisibleRange = function getVisibleRange(_ref3) {
|
||||
var containerSize = _ref3.containerSize,
|
||||
offset = _ref3.offset,
|
||||
overscanCount = _ref3.overscanCount;
|
||||
|
||||
var totalSize = this.getTotalSize();
|
||||
|
||||
if (totalSize === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
var maxOffset = offset + containerSize;
|
||||
var start = this._findNearestItem(offset);
|
||||
var stop = start;
|
||||
|
||||
var datum = this.getSizeAndPositionForIndex(start);
|
||||
offset = datum.offset + datum.size;
|
||||
|
||||
while (offset < maxOffset && stop < this._itemCount - 1) {
|
||||
stop++;
|
||||
offset += this.getSizeAndPositionForIndex(stop).size;
|
||||
}
|
||||
|
||||
if (overscanCount) {
|
||||
start = Math.max(0, start - overscanCount);
|
||||
stop = Math.min(stop + overscanCount, this._itemCount);
|
||||
}
|
||||
|
||||
return {
|
||||
start: start,
|
||||
stop: stop
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all cached values for items after the specified index.
|
||||
* This method should be called for any item that has changed its size.
|
||||
* It will not immediately perform any calculations; they'll be performed the next time getSizeAndPositionForIndex() is called.
|
||||
*/
|
||||
|
||||
|
||||
SizeAndPositionManager.prototype.resetItem = function resetItem(index) {
|
||||
this._lastMeasuredIndex = Math.min(this._lastMeasuredIndex, index - 1);
|
||||
};
|
||||
|
||||
SizeAndPositionManager.prototype._binarySearch = function _binarySearch(_ref4) {
|
||||
var low = _ref4.low,
|
||||
high = _ref4.high,
|
||||
offset = _ref4.offset;
|
||||
|
||||
var middle = void 0;
|
||||
var currentOffset = void 0;
|
||||
|
||||
while (low <= high) {
|
||||
middle = low + Math.floor((high - low) / 2);
|
||||
currentOffset = this.getSizeAndPositionForIndex(middle).offset;
|
||||
|
||||
if (currentOffset === offset) {
|
||||
return middle;
|
||||
} else if (currentOffset < offset) {
|
||||
low = middle + 1;
|
||||
} else if (currentOffset > offset) {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (low > 0) {
|
||||
return low - 1;
|
||||
}
|
||||
};
|
||||
|
||||
SizeAndPositionManager.prototype._exponentialSearch = function _exponentialSearch(_ref5) {
|
||||
var index = _ref5.index,
|
||||
offset = _ref5.offset;
|
||||
|
||||
var interval = 1;
|
||||
|
||||
while (index < this._itemCount && this.getSizeAndPositionForIndex(index).offset < offset) {
|
||||
index += interval;
|
||||
interval *= 2;
|
||||
}
|
||||
|
||||
return this._binarySearch({
|
||||
high: Math.min(index, this._itemCount - 1),
|
||||
low: Math.floor(index / 2),
|
||||
offset: offset
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for the item (index) nearest the specified offset.
|
||||
*
|
||||
* If no exact match is found the next lowest item index will be returned.
|
||||
* This allows partially visible items (with offsets just before/above the fold) to be visible.
|
||||
*/
|
||||
|
||||
|
||||
SizeAndPositionManager.prototype._findNearestItem = function _findNearestItem(offset) {
|
||||
if (isNaN(offset)) {
|
||||
throw Error('Invalid offset ' + offset + ' specified');
|
||||
}
|
||||
|
||||
// Our search algorithms find the nearest match at or below the specified offset.
|
||||
// So make sure the offset is at least 0 or no match will be found.
|
||||
offset = Math.max(0, offset);
|
||||
|
||||
var lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
|
||||
var lastMeasuredIndex = Math.max(0, this._lastMeasuredIndex);
|
||||
|
||||
if (lastMeasuredSizeAndPosition.offset >= offset) {
|
||||
// If we've already measured items within this range just use a binary search as it's faster.
|
||||
return this._binarySearch({
|
||||
high: lastMeasuredIndex,
|
||||
low: 0,
|
||||
offset: offset
|
||||
});
|
||||
} else {
|
||||
// If we haven't yet measured this high, fallback to an exponential search with an inner binary search.
|
||||
// The exponential search avoids pre-computing sizes for the full set of items as a binary search would.
|
||||
// The overall complexity for this approach is O(log n).
|
||||
return this._exponentialSearch({
|
||||
index: lastMeasuredIndex,
|
||||
offset: offset
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return SizeAndPositionManager;
|
||||
}();
|
||||
|
||||
export { SizeAndPositionManager as default };
|
||||
@@ -0,0 +1,200 @@
|
||||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
|
||||
|
||||
import morphdom from 'morphdom';
|
||||
import SizeAndPositionManager from './SizeAndPositionManager';
|
||||
|
||||
var STYLE_INNER = 'position:relative; overflow:hidden; width:100%; min-height:100%; will-change: transform;';
|
||||
var STYLE_CONTENT = 'position:absolute; top:0; left:0; height:100%; width:100%; overflow:visible;';
|
||||
|
||||
var VirtualizedList = function () {
|
||||
function VirtualizedList(container, options) {
|
||||
var _this = this;
|
||||
|
||||
_classCallCheck(this, VirtualizedList);
|
||||
|
||||
this.getRowHeight = function (_ref) {
|
||||
var index = _ref.index;
|
||||
var rowHeight = _this.options.rowHeight;
|
||||
|
||||
|
||||
if (typeof rowHeight === 'function') {
|
||||
return rowHeight(index);
|
||||
}
|
||||
|
||||
return Array.isArray(rowHeight) ? rowHeight[index] : rowHeight;
|
||||
};
|
||||
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
|
||||
// Initialization
|
||||
this.state = {};
|
||||
this._initializeSizeAndPositionManager(options.rowCount);
|
||||
|
||||
// Binding
|
||||
this.render = this.render.bind(this);
|
||||
this.handleScroll = this.handleScroll.bind(this);
|
||||
|
||||
// Lifecycle Methods
|
||||
this.componentDidMount();
|
||||
}
|
||||
|
||||
VirtualizedList.prototype.componentDidMount = function componentDidMount() {
|
||||
var _this2 = this;
|
||||
|
||||
var _options = this.options,
|
||||
onMount = _options.onMount,
|
||||
initialScrollTop = _options.initialScrollTop,
|
||||
initialIndex = _options.initialIndex,
|
||||
height = _options.height;
|
||||
|
||||
var offset = initialScrollTop || initialIndex != null && this.getRowOffset(initialIndex) || 0;
|
||||
var inner = this.inner = document.createElement('div');
|
||||
var content = this.content = document.createElement('div');
|
||||
|
||||
inner.setAttribute('style', STYLE_INNER);
|
||||
content.setAttribute('style', STYLE_CONTENT);
|
||||
inner.appendChild(content);
|
||||
this.container.appendChild(inner);
|
||||
|
||||
this.setState({
|
||||
offset: offset,
|
||||
height: height
|
||||
}, function () {
|
||||
if (offset) {
|
||||
_this2.container.scrollTop = offset;
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
_this2.container.addEventListener('scroll', _this2.handleScroll);
|
||||
|
||||
if (typeof onMount === 'function') {
|
||||
onMount();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
VirtualizedList.prototype._initializeSizeAndPositionManager = function _initializeSizeAndPositionManager(count) {
|
||||
this._sizeAndPositionManager = new SizeAndPositionManager({
|
||||
itemCount: count,
|
||||
itemSizeGetter: this.getRowHeight,
|
||||
estimatedItemSize: this.options.estimatedRowHeight || 100
|
||||
});
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.setState = function setState() {
|
||||
var _this3 = this;
|
||||
|
||||
var state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
||||
var callback = arguments[1];
|
||||
|
||||
this.state = Object.assign(this.state, state);
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
_this3.render();
|
||||
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.resize = function resize(height, callback) {
|
||||
this.setState({
|
||||
height: height
|
||||
}, callback);
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.handleScroll = function handleScroll(e) {
|
||||
var onScroll = this.options.onScroll;
|
||||
|
||||
var offset = this.container.scrollTop;
|
||||
|
||||
this.setState({ offset: offset });
|
||||
|
||||
if (typeof onScroll === 'function') {
|
||||
onScroll(offset, e);
|
||||
}
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.getRowOffset = function getRowOffset(index) {
|
||||
var _sizeAndPositionManag = this._sizeAndPositionManager.getSizeAndPositionForIndex(index),
|
||||
offset = _sizeAndPositionManag.offset;
|
||||
|
||||
return offset;
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.scrollToIndex = function scrollToIndex(index, alignment) {
|
||||
var height = this.state.height;
|
||||
|
||||
var offset = this._sizeAndPositionManager.getUpdatedOffsetForIndex({
|
||||
align: alignment,
|
||||
containerSize: height,
|
||||
targetIndex: index
|
||||
});
|
||||
|
||||
this.container.scrollTop = offset;
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.setRowCount = function setRowCount(count) {
|
||||
this._initializeSizeAndPositionManager(count);
|
||||
this.render();
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.onRowsRendered = function onRowsRendered(renderedRows) {
|
||||
var onRowsRendered = this.options.onRowsRendered;
|
||||
|
||||
|
||||
if (typeof onRowsRendered === 'function') {
|
||||
onRowsRendered(renderedRows);
|
||||
}
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.destroy = function destroy() {
|
||||
this.container.removeEventListener('scroll', this.handleScroll);
|
||||
this.container.innerHTML = '';
|
||||
};
|
||||
|
||||
VirtualizedList.prototype.render = function render() {
|
||||
var _options2 = this.options,
|
||||
overscanCount = _options2.overscanCount,
|
||||
renderRow = _options2.renderRow;
|
||||
var _state = this.state,
|
||||
height = _state.height,
|
||||
_state$offset = _state.offset,
|
||||
offset = _state$offset === undefined ? 0 : _state$offset;
|
||||
|
||||
var _sizeAndPositionManag2 = this._sizeAndPositionManager.getVisibleRange({
|
||||
containerSize: height,
|
||||
offset: offset,
|
||||
overscanCount: overscanCount
|
||||
}),
|
||||
start = _sizeAndPositionManag2.start,
|
||||
stop = _sizeAndPositionManag2.stop;
|
||||
|
||||
var fragment = document.createDocumentFragment();
|
||||
|
||||
for (var index = start; index <= stop; index++) {
|
||||
fragment.appendChild(renderRow(index));
|
||||
}
|
||||
|
||||
this.inner.style.height = this._sizeAndPositionManager.getTotalSize() + 'px';
|
||||
this.content.style.top = this.getRowOffset(start) + 'px';
|
||||
|
||||
morphdom(this.content, fragment, {
|
||||
childrenOnly: true,
|
||||
getNodeKey: function getNodeKey(node) {
|
||||
return node.nodeIndex;
|
||||
}
|
||||
});
|
||||
|
||||
this.onRowsRendered({
|
||||
startIndex: start,
|
||||
stopIndex: stop
|
||||
});
|
||||
};
|
||||
|
||||
return VirtualizedList;
|
||||
}();
|
||||
|
||||
export { VirtualizedList as default };
|
||||
2
crates/libs/lib-components/assets/js/vlist/es/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './VirtualList';
|
||||
export { default as InfiniteVirtualList } from './InfiniteVirtualList';
|
||||
1420
crates/libs/lib-components/assets/js/vlist/virtualized-list.js
Normal file
6
crates/libs/lib-components/assets/js/vlist/virtualized-list.min.js
vendored
Normal file
4
crates/libs/lib-components/assets/svg/asterisk.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M214.86,180.12a8,8,0,0,1-11,2.74L136,142.13V216a8,8,0,0,1-16,0V142.13L52.12,182.86a8,8,0,1,1-8.23-13.72L112.45,128,43.89,86.86a8,8,0,1,1,8.23-13.72L120,113.87V40a8,8,0,0,1,16,0v73.87l67.88-40.73a8,8,0,1,1,8.23,13.72L143.55,128l68.56,41.14A8,8,0,0,1,214.86,180.12Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
4
crates/libs/lib-components/assets/svg/book-open-text.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M232,48H160a40,40,0,0,0-32,16A40,40,0,0,0,96,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,24,8,8,0,0,0,16,0,24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM96,192H32V64H96a24,24,0,0,1,24,24V200A39.81,39.81,0,0,0,96,192Zm128,0H160a39.81,39.81,0,0,0-24,8V88a24,24,0,0,1,24-24h64ZM160,88h40a8,8,0,0,1,0,16H160a8,8,0,0,1,0-16Zm48,40a8,8,0,0,1-8,8H160a8,8,0,0,1,0-16h40A8,8,0,0,1,208,128Zm0,32a8,8,0,0,1-8,8H160a8,8,0,0,1,0-16h40A8,8,0,0,1,208,160Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 562 B |
4
crates/libs/lib-components/assets/svg/book.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M208,24H72A32,32,0,0,0,40,56V224a8,8,0,0,0,8,8H192a8,8,0,0,0,0-16H56a16,16,0,0,1,16-16H208a8,8,0,0,0,8-8V32A8,8,0,0,0,208,24Zm-8,160H72a31.82,31.82,0,0,0-16,4.29V56A16,16,0,0,1,72,40H200Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M205.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L131.31,128ZM51.31,128l74.35-74.34a8,8,0,0,0-11.32-11.32l-80,80a8,8,0,0,0,0,11.32l80,80a8,8,0,0,0,11.32-11.32Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 299 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M141.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L124.69,128,50.34,53.66A8,8,0,0,1,61.66,42.34l80,80A8,8,0,0,1,141.66,133.66Zm80-11.32-80-80a8,8,0,0,0-11.32,11.32L204.69,128l-74.35,74.34a8,8,0,0,0,11.32,11.32l80-80A8,8,0,0,0,221.66,122.34Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
4
crates/libs/lib-components/assets/svg/caret-right.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 219 B |
4
crates/libs/lib-components/assets/svg/eye.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 701 B |
4
crates/libs/lib-components/assets/svg/info.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
4
crates/libs/lib-components/assets/svg/list.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" >
|
||||
<path
|
||||
d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -1 +0,0 @@
|
||||
Hi this is a testa d
|
||||
4
crates/libs/lib-components/assets/svg/warning.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 543 B |
4
crates/libs/lib-components/assets/svg/x-circle.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M165.66,101.66,139.31,128l26.35,26.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
4
crates/libs/lib-components/assets/svg/x.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -15,5 +15,5 @@ fn main() {
|
||||
|
||||
// Tell Cargo to rerun build.rs if any files in the src or assets directory change
|
||||
println!("cargo:rerun-if-changed=src/");
|
||||
println!("cargo:rerun-if-changed=assets/");
|
||||
// println!("cargo:rerun-if-changed=assets/");
|
||||
}
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
@tailwind base;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* .virtual-table>div>div>div {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content auto;
|
||||
/* grid-template-columns: auto auto auto; */
|
||||
/* gap: 0.5rem; */
|
||||
/* } */
|
||||
19
crates/libs/lib-components/package-lock.json
generated
@@ -4,9 +4,11 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"hasInstallScript": true,
|
||||
"devDependencies": {
|
||||
"daisyui": "^4.12.14",
|
||||
"tailwindcss": "^3.4.14"
|
||||
"tailwindcss": "^3.4.14",
|
||||
"virtualized-list": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -680,6 +682,12 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/morphdom": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.4.tgz",
|
||||
"integrity": "sha512-ATTbWMgGa+FaMU3FhnFYB6WgulCqwf6opOll4CBzmVDTLvPMmUPrEv8CudmLPK0MESa64+6B89fWOxP3+YIlxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -1309,6 +1317,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/virtualized-list": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/virtualized-list/-/virtualized-list-2.2.0.tgz",
|
||||
"integrity": "sha512-LiTd16NyFvxbse3iB9ZM7RwVp5IuqaALBWuosHZFz2C2As8OeNpH1NjwEzk2PvpCgFehm1gpFE+/yF9Twuf9Qw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"morphdom": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"scripts": {
|
||||
"build-css": "tailwindcss -i input.css -o static/css/tailwind.css -m"
|
||||
"build-css": "tailwindcss -i input.css -o assets/css/tailwind.css -m",
|
||||
"watch-css": "tailwindcss -i input.css -o assets/css/tailwind.css -m -w",
|
||||
"postinstall": "mkdir -p assets/js/vlist/ && cp node_modules/virtualized-list/umd/* assets/js/vlist/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"daisyui": "^4.12.14",
|
||||
"tailwindcss": "^3.4.14"
|
||||
"tailwindcss": "^3.4.14",
|
||||
"virtualized-list": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
`npx tailwindcss -i input.css -o static/css/tailwind.css -m`
|
||||
`-m` for minimizing output
|
||||
|
||||
# Icons
|
||||
Icons: https://phosphoricons.com/?q=%22%22
|
||||
|
||||
# TODO:
|
||||
|
||||
|
||||
make files available as files. like they can be generated and saved at compiletime from a service using these components.
|
||||
make simple alert component.
|
||||
test this component by using it as a server error.
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
use maud::{html, Markup, Render};
|
||||
use crate::icons::{TEST};
|
||||
use crate::icons::{Icon, LIST};
|
||||
use crate::navbar::Navbar;
|
||||
use crate::notification::Notification;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Base {
|
||||
pub title: String
|
||||
pub title: String,
|
||||
pub content: Markup,
|
||||
pub notifications: Vec<Notification>,
|
||||
}
|
||||
|
||||
impl Base {
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
pub fn new(title: impl Into<String>, content: Markup) -> Self {
|
||||
Self {
|
||||
title: title.into()
|
||||
title: title.into(),
|
||||
content,
|
||||
notifications: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_notification(notification: Notification) -> Self {
|
||||
Self {
|
||||
title: "Notification".to_string(),
|
||||
content: html!(
|
||||
div ."container mx-auto text-center my-24 text-xl font-bold" {
|
||||
(notification.title)
|
||||
}
|
||||
),
|
||||
notifications: vec![notification],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,15 +35,25 @@ impl Base {
|
||||
impl Render for Base {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
head {
|
||||
link rel="stylesheet" type="text/css" href="css/tailwind.css" {}
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
(maud::DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
link rel="stylesheet" type="text/css" href="/css/tailwind.css" {}
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
|
||||
title { (self.title )}
|
||||
}
|
||||
div {
|
||||
h1 { "Base" }
|
||||
h3 { (TEST) }
|
||||
title { (self.title )}
|
||||
}
|
||||
body ."" {
|
||||
(Navbar::new())
|
||||
(self.content)
|
||||
div #"notifications" ."absolute bottom-0 p-4 w-full" {
|
||||
div ."container mx-auto" {
|
||||
@for notification in &self.notifications {
|
||||
(notification.render())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
212
crates/libs/lib-components/src/book_card.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use lib_core::model::origin::Origin;
|
||||
use lib_core::model::{book::Book, chapter::ChapterStub};
|
||||
use maud::{html, Markup, Render};
|
||||
use time::macros::format_description;
|
||||
|
||||
use crate::icons;
|
||||
use crate::icons::Icon;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BookCard {
|
||||
agg: (Book, Vec<ChapterStub>),
|
||||
}
|
||||
|
||||
impl BookCard {
|
||||
pub fn new(book_agg: (Book, Vec<ChapterStub>)) -> Self {
|
||||
Self { agg: book_agg }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BookCard {
|
||||
fn render(&self) -> Markup {
|
||||
let latest_chapter_date = self
|
||||
.agg
|
||||
.1
|
||||
.last()
|
||||
.map(|c| {
|
||||
c.updated_at
|
||||
.format(format_description!("[day] [month repr:short] [year]"))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
html! {
|
||||
div ."card bg-base-300 shadow-xl w-full max-width-96 md:width-80" {
|
||||
figure ."relative" {
|
||||
img class="object-cover w-full rounded-xl h-40" src=(self.agg.clone().0.cover.unwrap_or_default());
|
||||
div ."absolute bottom-0 right-0 p-2" {
|
||||
a ."btn btn-accent btn-circle" href=(format!("/books/{}", self.agg.0.id)) {
|
||||
// "View"
|
||||
div ."h-5 w-5 fill-current" {
|
||||
(Icon(icons::BOOK))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div ."card-body" {
|
||||
div ."grid grid-cols-[auto_min-content] gap-2" {
|
||||
h2 ."card-title" {
|
||||
(self.agg.0.title)
|
||||
}
|
||||
div {
|
||||
(status_badge(self.agg.0.status.as_str()))
|
||||
}
|
||||
}
|
||||
div ."grid grid-cols-[auto_min-content] gap-2" {
|
||||
div ."text-sm text-base-content" {
|
||||
(self.agg.0.authors.join(", "))
|
||||
}
|
||||
div ."text-sm text-base-content whitespace-nowrap" {
|
||||
(latest_chapter_date)
|
||||
}
|
||||
}
|
||||
div ."grid grid-cols-[min-content_auto] gap-2" {
|
||||
div ."font-semibold" {
|
||||
"Genres: "
|
||||
}
|
||||
div ."text-pretty" {
|
||||
(self.agg.0.genres.join(", "))
|
||||
}
|
||||
}
|
||||
div ."divider" {
|
||||
"latest"
|
||||
}
|
||||
div {
|
||||
@for chapter in &self.agg.1 {
|
||||
div class="text-ellipsis overflow-hidden whitespace-nowrap hover:whitespace-normal" {
|
||||
a href=(format!("/book/{}/chapter/{}", self.agg.0.id, chapter.id)) {
|
||||
@if chapter.title.is_empty() {
|
||||
(format!("Vol. {} Ch. {}", chapter.volume, chapter.number))
|
||||
} @else {
|
||||
(format!("Vol. {} Ch. {} - {}", chapter.volume, chapter.number, chapter.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CardList {
|
||||
cards: Vec<Markup>,
|
||||
}
|
||||
|
||||
impl CardList {
|
||||
pub fn new(cards: Vec<Markup>) -> Self {
|
||||
Self { cards }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CardList {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
div ."grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 justify-items-center" {
|
||||
@for card in &self.cards {
|
||||
(card)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FullBookCard {
|
||||
book: Book,
|
||||
origin: Origin,
|
||||
first_chapter: Option<ChapterStub>,
|
||||
}
|
||||
|
||||
impl FullBookCard {
|
||||
pub fn new(
|
||||
book: Book,
|
||||
first_chapter: Option<ChapterStub>,
|
||||
origin: Origin,
|
||||
) -> Self {
|
||||
Self {
|
||||
book,
|
||||
first_chapter,
|
||||
origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for FullBookCard {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
div ."card bg-none md:card-side" {
|
||||
figure {
|
||||
img class="object-cover w-full h-80" src=(self.book.clone().cover.unwrap_or_default());
|
||||
}
|
||||
div ."card-body" {
|
||||
div ."grid grid-cols-[auto_min-content] gap-2" {
|
||||
h2 ."card-title" {
|
||||
(self.book.title)
|
||||
}
|
||||
div {
|
||||
(status_badge(self.book.status.as_str()))
|
||||
}
|
||||
}
|
||||
div ."grid grid-cols-[auto_min-content] gap-2" {
|
||||
div ."text-sm text-base-content" {
|
||||
(self.book.authors.join(", "))
|
||||
}
|
||||
div ."text-sm text-base-content whitespace-nowrap" {
|
||||
(self.book.updated_at.format(format_description!("[day] [month repr:short] [year]")).unwrap())
|
||||
}
|
||||
}
|
||||
div ."grid grid-cols-[min-content_auto] gap-2" {
|
||||
div ."font-semibold" {
|
||||
"Genres: "
|
||||
}
|
||||
div ."text-pretty" {
|
||||
(self.book.genres.join(", "))
|
||||
}
|
||||
}
|
||||
div ."grid grid-cols-[auto_min-content] gap-2 justify-content-between" {
|
||||
div ."mr-auto" {
|
||||
span ."font-semibold" { "Origin: " }
|
||||
(self.origin.name)
|
||||
}
|
||||
}
|
||||
div ."card-actions ml-auto" {
|
||||
@if let Some(chapter) = &self.first_chapter {
|
||||
a ."btn btn-secondary" href=(format!("/books/{}/chapters/{}", self.book.id, chapter.number)) {
|
||||
"First Chapter"
|
||||
div ."w-5 h-5 fill-current" {(Icon(icons::BOOK_OPEN_TEXT))}
|
||||
}
|
||||
}
|
||||
a ."btn btn-accent" href=(self.book.origin_book_url) target="_blank" {
|
||||
"Origin"
|
||||
div ."w-5 h-5 fill-current" {(Icon((icons::ASTERISK)))}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_badge(status: &str) -> Markup {
|
||||
let color_class = match status {
|
||||
"completed" => "badge-secondary",
|
||||
"ongoing" => "badge-primary",
|
||||
"hiatus" => "badge-error",
|
||||
_ => "",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let display_status = match status {
|
||||
"completed" => "Completed",
|
||||
"ongoing" => "Ongoing",
|
||||
"hiatus" => "Hiatus",
|
||||
_ => "Unknown Status",
|
||||
};
|
||||
|
||||
html! {
|
||||
div .(color_class + " badge badge-outline") {
|
||||
(display_status)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
crates/libs/lib-components/src/chapters_list.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use lib_core::model::book::Book;
|
||||
use maud::{html, Markup, PreEscaped};
|
||||
|
||||
use lib_core::model::chapter::ChapterStub;
|
||||
use crate::icons::Icon;
|
||||
use crate::icons;
|
||||
|
||||
pub fn chapter_list(book: Book, chapters: Vec<ChapterStub>, current_chapter: Option<ChapterStub>) -> Markup {
|
||||
html! {
|
||||
div ."flex items-center gap-2" {
|
||||
div ."fill-current h-5 w-5 text-secondary" {
|
||||
(Icon(icons::CARET_RIGHT))
|
||||
}
|
||||
h2 ."font-semibold text-lg" { "Chapters" }
|
||||
}
|
||||
@if chapters.is_empty() {
|
||||
div {
|
||||
"No chapters available"
|
||||
}
|
||||
} @else {
|
||||
div ."grid grid-cols-[min-content_min-content_auto] gap-2" {
|
||||
div ."py-3 px-4 text-sm w-14" {("Vol.")}
|
||||
div ."py-3 px-4 text-sm w-14" {("Ch.")}
|
||||
div ."py-3 px-4 text-sm w-14" {("Title")}
|
||||
}
|
||||
div #"chapters" ."h-96 overflow-scroll virtual-table" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn js(chapters: Vec<ChapterStub>, book: Book) -> Markup {
|
||||
let js_chapters = chapters
|
||||
.iter()
|
||||
.map(|chapter| {
|
||||
format!(
|
||||
"{{ volume: {}, number: {}, title: \"{}\" }}",
|
||||
chapter.volume, chapter.number, chapter.title.replace(r#"""#, r#"\""#)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
html!(
|
||||
script src="/js/vlist/virtualized-list.min.js" {};
|
||||
script type="module" {
|
||||
(PreEscaped(format!("
|
||||
const rows = [{}];
|
||||
|
||||
const container = document.getElementById('chapters');
|
||||
|
||||
const VirtualizedList = window.VirtualizedList.default;
|
||||
|
||||
const virtualizedList = new VirtualizedList(container, {{
|
||||
height: 384, // The height of the container
|
||||
rowCount: rows.length,
|
||||
overscanCount: 5,
|
||||
renderRow: index => {{
|
||||
let element = document.createElement('a');
|
||||
|
||||
let obj = {{
|
||||
volume: '',
|
||||
number: '',
|
||||
title: '',
|
||||
}}
|
||||
|
||||
if (index < rows.length) {{
|
||||
obj = rows[index];
|
||||
}}
|
||||
|
||||
let vol_el = document.createElement('div');
|
||||
vol_el.innerHTML = obj.volume;
|
||||
vol_el.classList.add('py-3', 'px-4', 'w-14', 'h-12');
|
||||
|
||||
let ch_el = document.createElement('div');
|
||||
ch_el.innerHTML = obj.number;
|
||||
ch_el.classList.add('py-3', 'px-4', 'w-14', 'h-12');
|
||||
|
||||
let title_el = document.createElement('div');
|
||||
title_el.innerHTML = obj.title;
|
||||
title_el.classList.add('py-3', 'px-4', 'h-12');
|
||||
|
||||
let content = '<div class=\"py-3 px-4 w-14 h-12\">' + obj.volume + '</div><div class=\"py-3 px-4 w-14 h-12\">' + obj.number + '</div><div class=\"py-3 px-4 h-12\">' + obj.title + '</div>';
|
||||
|
||||
element.appendChild(vol_el);
|
||||
element.appendChild(ch_el);
|
||||
element.appendChild(title_el);
|
||||
element.href = `/books/{}/chapters/${{obj.number}}`;
|
||||
element.classList.add('grid', 'grid-cols-[min-content_min-content_auto]', 'gap-2', 'hover:bg-base-200', 'hover:shadow-md', 'transition', 'duration-200', 'ease-in-out');
|
||||
|
||||
return element;
|
||||
}},
|
||||
rowHeight: 48, // h-12 in px
|
||||
}});
|
||||
", js_chapters, book.id)))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
use lib_macros::icons_from_dir;
|
||||
use maud::{PreEscaped, Render, Markup};
|
||||
|
||||
pub struct Icon(pub &'static str);
|
||||
|
||||
impl Render for Icon {
|
||||
fn render(&self) -> Markup {
|
||||
PreEscaped(self.0.into())
|
||||
// html! {
|
||||
// svg fill="currentColor" viewBox="0 0 20 20" {
|
||||
// path fill-rule="evenodd" clip-rule="evenodd" d=(self.0) {}
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
icons_from_dir!("assets/svg");
|
||||
@@ -8,6 +8,9 @@ pub mod notification;
|
||||
pub mod base;
|
||||
pub mod navbar;
|
||||
pub mod icons;
|
||||
pub mod book_card;
|
||||
pub mod chapters_list;
|
||||
pub mod pages;
|
||||
|
||||
use rust_embed::Embed;
|
||||
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
use maud::{html, Markup, Render};
|
||||
|
||||
use crate::icons::{Icon, LIST};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Navbar {
|
||||
pub title: String
|
||||
}
|
||||
pub struct Navbar {}
|
||||
|
||||
impl Navbar {
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into()
|
||||
}
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Navbar {
|
||||
fn render(&self) -> Markup {
|
||||
let menu_items = vec![
|
||||
("Home", "/"),
|
||||
("Completed", "/completed"),
|
||||
// ("Contact", "/contact"),
|
||||
];
|
||||
|
||||
html! {
|
||||
div ."navbar bg-base-100" {
|
||||
div ."navbar bg-base-100 container mx-auto" {
|
||||
div ."navbar-start" {
|
||||
div .dropdown {
|
||||
div tabindex="0" class="fill-current h-5, w-5 btn btn-ghost btn-circle" {
|
||||
(Icon(LIST))
|
||||
}
|
||||
ul ."menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow" {
|
||||
@for (name, link) in menu_items {
|
||||
li {
|
||||
a href=(link) { (name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div ."navbar-center" {
|
||||
"MediaManager"
|
||||
}
|
||||
div ."navbar-end" {
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use maud::{html, Markup, Render};
|
||||
use maud::{html, Markup, PreEscaped, Render};
|
||||
use crate::icons::Icon;
|
||||
use crate::icons;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Notification{
|
||||
@@ -28,20 +30,72 @@ impl Notification{
|
||||
impl Render for Notification{
|
||||
fn render(&self) -> Markup{
|
||||
html!{
|
||||
div role="alert" .(format!("alert {}", self.level.to_alert_class())) {
|
||||
h3 ."font-bold" {
|
||||
(self.title)
|
||||
div role="alert" .(self.level.to_alert_class() + "alert w-4/5 mx-auto max-w-3xl shadow-lg") {
|
||||
div ."w-6 h-6 fill-current" {
|
||||
(self.level.to_icon())
|
||||
}
|
||||
@if let Some(msg) = &self.message{
|
||||
div ."text-xs" {
|
||||
(msg)
|
||||
div ."mr-auto" {
|
||||
h3 ."font-bold" {
|
||||
(self.title)
|
||||
}
|
||||
@if let Some(msg) = &self.message{
|
||||
div ."text-xs" {
|
||||
(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
div ."btn btn-circle btn-sm" {
|
||||
div class="radial-progress" style="--value:0; --size:1.75rem" role="progressbar" {
|
||||
div. "w-5 h-5 fill-current" {
|
||||
(Icon(icons::X))
|
||||
}
|
||||
}
|
||||
}
|
||||
(js())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn js() -> Markup {
|
||||
html!(
|
||||
script {
|
||||
(PreEscaped({r#"
|
||||
{
|
||||
const notification = document.currentScript.parentElement;
|
||||
|
||||
const button = notification.querySelector('.btn');
|
||||
|
||||
console.log(button);
|
||||
|
||||
const set_progress = (value) => {
|
||||
const progress = notification.querySelector(".radial-progress");
|
||||
progress.style.setProperty("--value", value + 1);
|
||||
};
|
||||
|
||||
const life_time_max = 10000;
|
||||
let life_time = 0;
|
||||
let interval = 10;
|
||||
|
||||
const interval_id = setInterval(() => {
|
||||
life_time += interval;
|
||||
set_progress((life_time / life_time_max) * 100);
|
||||
if (life_time >= life_time_max) {
|
||||
clearInterval(interval_id);
|
||||
notification.remove();
|
||||
}
|
||||
}, interval);
|
||||
|
||||
button.onclick = () => {
|
||||
clearInterval(interval_id);
|
||||
notification.remove();
|
||||
};
|
||||
}
|
||||
"#}))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Level{
|
||||
Info,
|
||||
@@ -51,11 +105,20 @@ pub enum Level{
|
||||
|
||||
impl Level{
|
||||
pub fn to_alert_class(&self) -> String{
|
||||
match self{
|
||||
format!(" {} ", match self{
|
||||
Level::Info => "alert-info",
|
||||
Level::Warning => "alert-warning",
|
||||
Level::Error => "alert-error"
|
||||
}.to_string()
|
||||
}.to_string())
|
||||
}
|
||||
|
||||
pub fn to_icon(&self) -> icons::Icon{
|
||||
Icon(match self{
|
||||
Level::Info => icons::INFO,
|
||||
Level::Warning => icons::WARNING,
|
||||
Level::Error => icons::X_CIRCLE,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
140
crates/libs/lib-components/src/pages/book.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use lib_core::model::{
|
||||
book::Book,
|
||||
chapter::{ChapterStub, Chapter},
|
||||
origin::Origin,
|
||||
};
|
||||
use maud::{html, Markup, PreEscaped, Render};
|
||||
|
||||
use crate::{
|
||||
base::Base,
|
||||
book_card::{BookCard, CardList, FullBookCard},
|
||||
icons,
|
||||
icons::Icon,
|
||||
};
|
||||
|
||||
pub struct BookPage {
|
||||
pub book: Book,
|
||||
pub stubs: Vec<ChapterStub>,
|
||||
pub origin: Origin,
|
||||
}
|
||||
|
||||
impl BookPage {
|
||||
pub fn new(book: Book, stubs: Vec<ChapterStub>, origin: Origin) -> Self {
|
||||
Self {
|
||||
book,
|
||||
stubs,
|
||||
origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BookPage {
|
||||
fn render(&self) -> Markup {
|
||||
let js_chapters = self
|
||||
.stubs
|
||||
.iter()
|
||||
.map(|chapter| {
|
||||
format!(
|
||||
"{{ volume: {}, number: {}, title: \"{}\" }}",
|
||||
chapter.volume, chapter.number, chapter.title.replace(r#"""#, r#"\""#)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
let content = html! {
|
||||
div ."bg-base-300" {
|
||||
div ."hero bg-base-300 p-2 py-4 shadow-xl container mx-auto" {
|
||||
(FullBookCard::new(self.book.clone(), self.stubs.last().cloned(), self.origin.clone()).render())
|
||||
}
|
||||
}
|
||||
div ."p-2 container mx-auto" {
|
||||
div ."flex items-center gap-2" {
|
||||
div ."fill-current h-5 w-5 text-secondary" {
|
||||
(Icon(icons::CARET_RIGHT))
|
||||
}
|
||||
h2 ."font-semibold text-lg text-center" { "Summary" }
|
||||
}
|
||||
div ."text-pretty p-2 flex flex-col space-y-2" {
|
||||
(PreEscaped(self.book.summary.clone()))
|
||||
}
|
||||
}
|
||||
div ."bg-base-300" {
|
||||
div ."p-2 container mx-auto" {
|
||||
// script src="/js/vlist/virtualized-list.min.js" {};
|
||||
script src="/js/vlist/virtualized-list.min.js" {};
|
||||
script type="module" {
|
||||
(PreEscaped(format!("
|
||||
const rows = [{}];
|
||||
|
||||
const container = document.getElementById('chapters');
|
||||
|
||||
const VirtualizedList = window.VirtualizedList.default;
|
||||
|
||||
const virtualizedList = new VirtualizedList(container, {{
|
||||
height: 384, // The height of the container
|
||||
rowCount: rows.length,
|
||||
overscanCount: 5,
|
||||
renderRow: index => {{
|
||||
let element = document.createElement('a');
|
||||
|
||||
let obj = {{
|
||||
volume: '',
|
||||
number: '',
|
||||
title: '',
|
||||
}}
|
||||
|
||||
if (index < rows.length) {{
|
||||
obj = rows[index];
|
||||
}}
|
||||
|
||||
let vol_el = document.createElement('div');
|
||||
vol_el.innerHTML = obj.volume;
|
||||
vol_el.classList.add('py-3', 'px-4', 'w-14', 'h-12');
|
||||
|
||||
let ch_el = document.createElement('div');
|
||||
ch_el.innerHTML = obj.number;
|
||||
ch_el.classList.add('py-3', 'px-4', 'w-14', 'h-12');
|
||||
|
||||
let title_el = document.createElement('div');
|
||||
title_el.innerHTML = obj.title;
|
||||
title_el.classList.add('py-3', 'px-4', 'h-12');
|
||||
|
||||
let content = '<div class=\"py-3 px-4 w-14 h-12\">' + obj.volume + '</div><div class=\"py-3 px-4 w-14 h-12\">' + obj.number + '</div><div class=\"py-3 px-4 h-12\">' + obj.title + '</div>';
|
||||
|
||||
element.appendChild(vol_el);
|
||||
element.appendChild(ch_el);
|
||||
element.appendChild(title_el);
|
||||
element.href = `/books/{}/chapters/${{obj.number}}`;
|
||||
element.classList.add('grid', 'grid-cols-[min-content_min-content_auto]', 'gap-2', 'hover:bg-base-200', 'hover:shadow-md', 'transition', 'duration-200', 'ease-in-out');
|
||||
|
||||
return element;
|
||||
}},
|
||||
rowHeight: 48, // h-12 in px
|
||||
}});
|
||||
", js_chapters, self.book.id)))
|
||||
}
|
||||
div ."flex items-center gap-2" {
|
||||
div ."fill-current h-5 w-5 text-secondary" {
|
||||
(Icon(icons::CARET_RIGHT))
|
||||
}
|
||||
h2 ."font-semibold text-lg" { "Chapters" }
|
||||
}
|
||||
@if self.stubs.is_empty() {
|
||||
div {
|
||||
"No chapters available"
|
||||
}
|
||||
} @else {
|
||||
div ."grid grid-cols-[min-content_min-content_auto] gap-2" {
|
||||
div ."py-3 px-4 text-sm w-14" {("Vol.")}
|
||||
div ."py-3 px-4 text-sm w-14" {("Ch.")}
|
||||
div ."py-3 px-4 text-sm w-14" {("Title")}
|
||||
}
|
||||
div #"chapters" ."h-96 overflow-scroll virtual-table" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Base::new("MediaManager", content).render()
|
||||
}
|
||||
}
|
||||
142
crates/libs/lib-components/src/pages/chapter.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use lib_core::model::{
|
||||
book::Book,
|
||||
chapter::{ChapterStub, Chapter},
|
||||
origin::Origin,
|
||||
};
|
||||
use maud::{html, Markup, PreEscaped, Render};
|
||||
|
||||
use crate::{
|
||||
base::Base,
|
||||
book_card::{BookCard, CardList, FullBookCard},
|
||||
icons,
|
||||
icons::Icon,
|
||||
};
|
||||
|
||||
pub struct ChapterPage {
|
||||
pub book: Book,
|
||||
pub chapter: Chapter,
|
||||
pub prev_chapter: Option<ChapterStub>,
|
||||
pub next_chapter: Option<ChapterStub>,
|
||||
}
|
||||
|
||||
impl ChapterPage {
|
||||
pub fn new(
|
||||
book: Book,
|
||||
chapter: Chapter,
|
||||
prev_chapter: Option<ChapterStub>,
|
||||
next_chapter: Option<ChapterStub>,
|
||||
) -> Self {
|
||||
Self { book, chapter, prev_chapter, next_chapter }
|
||||
}
|
||||
|
||||
fn pagination(&self) -> Markup {
|
||||
html! {
|
||||
div ."flex justify-center max-w-42" {
|
||||
div ."join my-6" {
|
||||
a
|
||||
class=(format!("join-item btn btn-primary btn-outline {}", self.prev_chapter.is_none().then_some("btn-disabled").unwrap_or_default()))
|
||||
href=[self.prev_chapter.clone().map(|c| format!("/books/{}/chapters/{}", self.book.id, c.number))] {
|
||||
div ."w-5 h-5 fill-current" {
|
||||
(Icon(icons::CARET_DOUBLE_LEFT))
|
||||
}
|
||||
}
|
||||
label for="chapters-drawer" ."join-item btn truncate btn-primary btn-outline" {
|
||||
(format!("Vol.: {} Ch.: {} - {}", self.chapter.volume, self.chapter.number, self.chapter.title))
|
||||
}
|
||||
a
|
||||
class=(format!("join-item btn btn-primary btn-outline {}", self.next_chapter.is_none().then_some("btn-disabled").unwrap_or_default()))
|
||||
href=[self.next_chapter.clone().map(|c| format!("/books/{}/chapters/{}", self.book.id, c.number))] {
|
||||
div ."w-5 h-5 fill-current" {
|
||||
(Icon(icons::CARET_DOUBLE_RIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fn chapter_list() -> String {
|
||||
// html!(
|
||||
// script src="/js/vlist/virtualized-list.min.js" {};
|
||||
// script type="module" {
|
||||
// (PreEscaped(format!("
|
||||
// const rows = [{}];
|
||||
|
||||
// const container = document.getElementById('chapters');
|
||||
|
||||
// const VirtualizedList = window.VirtualizedList.default;
|
||||
|
||||
// const virtualizedList = new VirtualizedList(container, {{
|
||||
// height: 384, // The height of the container
|
||||
// rowCount: rows.length,
|
||||
// overscanCount: 5,
|
||||
// renderRow: index => {{
|
||||
// let element = document.createElement('a');
|
||||
// let obj = rows[index];
|
||||
|
||||
// let vol_el = document.createElement('div');
|
||||
// vol_el.innerHTML = obj.volume;
|
||||
// vol_el.classList.add('py-3', 'px-4', 'w-14', 'h-12');
|
||||
|
||||
// let ch_el = document.createElement('div');
|
||||
// ch_el.innerHTML = obj.number;
|
||||
// ch_el.classList.add('py-3', 'px-4', 'w-14', 'h-12');
|
||||
|
||||
// let title_el = document.createElement('div');
|
||||
// title_el.innerHTML = obj.title;
|
||||
// title_el.classList.add('py-3', 'px-4', 'h-12');
|
||||
|
||||
// let content = '<div class=\"py-3 px-4 w-14 h-12\">' + obj.volume + '</div><div class=\"py-3 px-4 w-14 h-12\">' + obj.number + '</div><div class=\"py-3 px-4 h-12\">' + obj.title + '</div>';
|
||||
|
||||
// element.appendChild(vol_el);
|
||||
// element.appendChild(ch_el);
|
||||
// element.appendChild(title_el);
|
||||
// element.href = `/books/{}/chapter/${{obj.number}}`;
|
||||
// element.classList.add('grid', 'grid-cols-[min-content_min-content_auto]', 'gap-2', 'hover:bg-base-200', 'hover:shadow-md', 'transition', 'duration-200', 'ease-in-out');
|
||||
|
||||
// return element;
|
||||
// }},
|
||||
// rowHeight: 48, // h-12 in px
|
||||
// }});
|
||||
// ", js_chapters, self.book.id)))
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
|
||||
impl Render for ChapterPage {
|
||||
fn render(&self) -> Markup {
|
||||
// let js_chapters = self.stubs.iter().map(|chapter| {
|
||||
// format!("{{ volume: {}, number: {}, title: \"{}\" }}", chapter.volume, chapter.number, chapter.title)
|
||||
// }).collect::<Vec<String>>().join(",");
|
||||
|
||||
let pageination = self.pagination();
|
||||
|
||||
let content = html! {
|
||||
div ."bg-base-300" {
|
||||
div ."drawer h-full" {
|
||||
input type="checkbox" id="chapters-drawer" class="drawer-toggle";
|
||||
div ."drawer-content" {
|
||||
(pageination)
|
||||
div ."p-2 container mx-auto" {
|
||||
div ."flex flex-col space-y-4" {
|
||||
(PreEscaped(self.chapter.content.clone()))
|
||||
}
|
||||
}
|
||||
(pageination)
|
||||
}
|
||||
div ."drawer-side h-full absolute" {
|
||||
label for="chapters-drawer" ."drawer-overlay" {
|
||||
|
||||
}
|
||||
div ."w-40 max-w-1/5 bg-base-200 h-full" {
|
||||
"HI"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Base::new("MediaManager", content).render()
|
||||
}
|
||||
}
|
||||
34
crates/libs/lib-components/src/pages/index.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use lib_core::model::{book::Book, chapter::ChapterStub};
|
||||
use maud::{html, Markup, Render};
|
||||
|
||||
use crate::{base::Base, book_card::{BookCard, CardList}};
|
||||
|
||||
pub struct IndexPage {
|
||||
pub books_agg: Vec<(Book, Vec<ChapterStub>)>,
|
||||
}
|
||||
|
||||
impl IndexPage {
|
||||
pub fn new(books_agg: Vec<(Book, Vec<ChapterStub>)>) -> Self {
|
||||
Self { books_agg }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Render for IndexPage {
|
||||
fn render(&self) -> Markup {
|
||||
let book_list = CardList::new(self.books_agg.iter().map(|book_agg| BookCard::new(book_agg.clone()).render()).collect());
|
||||
|
||||
|
||||
|
||||
let content = html! {
|
||||
div {
|
||||
div ."p-4 container mx-auto" {
|
||||
div {
|
||||
(book_list)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Base::new("MediaManager", content).render()
|
||||
}
|
||||
}
|
||||
3
crates/libs/lib-components/src/pages/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod index;
|
||||
pub mod book;
|
||||
pub mod chapter;
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [],
|
||||
content: ["./src/**/*.rs"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct Book {
|
||||
pub authors: Vec<String>,
|
||||
pub genres: Vec<String>,
|
||||
pub status: String,
|
||||
pub search_rank: Option<f32>,
|
||||
// pub search_rank: Option<f32>,
|
||||
pub origin_book_url: String,
|
||||
pub origin_book_id: String,
|
||||
pub origin_id: i64,
|
||||
@@ -47,7 +47,7 @@ pub struct BookStub {
|
||||
pub origin_book_url: String,
|
||||
pub origin_book_id: String,
|
||||
pub origin_id: i64,
|
||||
pub search_rank: Option<f32>,
|
||||
// pub search_rank: Option<f32>,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
@@ -200,7 +200,23 @@ impl BookBmc {
|
||||
filter: Option<Vec<BookFilter>>,
|
||||
list_options: Option<ListOptions>,
|
||||
) -> Result<Vec<BookStub>> {
|
||||
base::list::<Self, _, _>(ctx, mm, filter, list_options, vec![]).await
|
||||
let mut expressions = vec![];
|
||||
|
||||
if let Some(search) = filter.clone().and_then(|filters| {
|
||||
filters
|
||||
.into_iter().find(|f| f.search.is_some())
|
||||
.and_then(|f| f.search)
|
||||
}) {
|
||||
expressions.push(Expr::cust_with_values(
|
||||
"ts_rank(searchable, websearch_to_tsquery($1)) as search_rank",
|
||||
vec![search],
|
||||
));
|
||||
} else {
|
||||
expressions.push(Expr::cust(
|
||||
"null as search_rank",
|
||||
));
|
||||
}
|
||||
base::list::<Self, _, _>(ctx, mm, filter, list_options, expressions).await
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
|
||||
@@ -12,6 +12,7 @@ pub fn rpc_router() -> RpcRouter {
|
||||
rpc_router!(
|
||||
// Same as RpcRouter::new().add...
|
||||
create_book,
|
||||
get_book,
|
||||
list_books,
|
||||
list_book_stubs,
|
||||
update_book,
|
||||
@@ -19,6 +20,12 @@ pub fn rpc_router() -> RpcRouter {
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_book(ctx: Ctx, mm: ModelManager, params: ParamsIded) -> Result<Book> {
|
||||
let item = BookBmc::get(&ctx, &mm, params.id).await?;
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
pub async fn create_book(
|
||||
ctx: Ctx,
|
||||
mm: ModelManager,
|
||||
|
||||
@@ -11,6 +11,7 @@ use lib_core::model::ModelManager;
|
||||
pub fn rpc_router() -> RpcRouter {
|
||||
rpc_router!(
|
||||
// Same as RpcRouter::new().add...
|
||||
get_origin,
|
||||
create_origin,
|
||||
list_origins,
|
||||
update_origin,
|
||||
@@ -31,6 +32,14 @@ pub async fn create_origin(
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
pub async fn get_origin(ctx: Ctx, mm: ModelManager, params: ParamsIded) -> Result<Origin> {
|
||||
let ParamsIded { id } = params;
|
||||
|
||||
let task = OriginBmc::get(&ctx, &mm, id).await?;
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
pub async fn list_origins(
|
||||
ctx: Ctx,
|
||||
mm: ModelManager,
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod api;
|
||||
pub use error::{Error, Result};
|
||||
|
||||
pub use lib_core::model::book::{BookForCreate, Book};
|
||||
pub use lib_core::model::chapter::{ChapterForCreate, ChapterStub, Chapter, ChapterStubForCreate};
|
||||
pub use lib_core::model::chapter::{ChapterForCreate, Chapter, ChapterStub, ChapterStubForCreate};
|
||||
pub use lib_core::model::origin::{OriginForCreate, Origin};
|
||||
|
||||
pub trait Api {
|
||||
|
||||
@@ -33,9 +33,15 @@ impl<S: Source + Clone, A: Api + Sync + Send> Scraper<S, A> {
|
||||
.filter(|e| !chapter_numbers.contains(&e.number))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let full_chapters = self.source.get_full_chapters(missing_chapters)?;
|
||||
let mut full_chapters = self.source.get_full_chapters(missing_chapters)?;
|
||||
full_chapters
|
||||
.par_iter()
|
||||
.sort_by(|a, b| {
|
||||
a.number.partial_cmp(&b.number).unwrap()
|
||||
});
|
||||
|
||||
full_chapters
|
||||
.iter()
|
||||
// .par_iter()
|
||||
.map(|f| self.api.upsert_chapter(f))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,23 @@ pub fn b64u_decode_to_string(b64u: &str) -> Result<String> {
|
||||
.ok_or(Error::FailToB64uDecode)
|
||||
}
|
||||
|
||||
pub fn b64_encode(content: impl AsRef<[u8]>) -> String {
|
||||
general_purpose::STANDARD.encode(content)
|
||||
}
|
||||
|
||||
pub fn b64_decode(b64u: &str) -> Result<Vec<u8>> {
|
||||
general_purpose::STANDARD
|
||||
.decode(b64u)
|
||||
.map_err(|_| Error::FailToB64uDecode)
|
||||
}
|
||||
|
||||
pub fn b64_decode_to_string(b64u: &str) -> Result<String> {
|
||||
b64u_decode(b64u)
|
||||
.ok()
|
||||
.and_then(|r| String::from_utf8(r).ok())
|
||||
.ok_or(Error::FailToB64uDecode)
|
||||
}
|
||||
|
||||
// region: --- Error
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
@@ -97,7 +97,7 @@ impl BoxnovelSource {
|
||||
msg: "summary not found".to_string(),
|
||||
url: url.clone(),
|
||||
})
|
||||
.map(|e| e.text().collect::<String>())
|
||||
.map(|e| e.inner_html().to_string())
|
||||
.unwrap_or("".to_string())
|
||||
.trim()
|
||||
.to_string();
|
||||
@@ -238,7 +238,7 @@ impl BoxnovelSource {
|
||||
|
||||
let res = self.client.get(url.clone()).send()?;
|
||||
|
||||
let cover_b64 = lib_utils::b64::b64u_encode(res.bytes()?);
|
||||
let cover_b64 = lib_utils::b64::b64_encode(res.bytes()?);
|
||||
|
||||
let d_url = format!("data:image/{};base64,{}", image_ending, cover_b64);
|
||||
|
||||
@@ -302,6 +302,24 @@ impl BoxnovelSource {
|
||||
url: book.origin_book_url.clone(),
|
||||
})?;
|
||||
|
||||
let pre_title = re_res
|
||||
.name("title")
|
||||
.ok_or(Error::InvalidChapterTitle {
|
||||
msg: "chapter title group missing".to_string(),
|
||||
title: text.clone(),
|
||||
url: book.origin_book_url.clone(),
|
||||
})?
|
||||
.as_str()
|
||||
.to_string();
|
||||
|
||||
let after_patter = r"^\s*(?:(?:Chapter\s?\d+)(?::?|\s?-)?\s*)?(?P<title>.*)$";
|
||||
let re = regex::Regex::new(after_patter).unwrap();
|
||||
let re_res = re.captures(&pre_title).ok_or(Error::InvalidChapterTitle {
|
||||
msg: "chapter title not in expected format".to_string(),
|
||||
title: text.clone(),
|
||||
url: book.origin_book_url.clone(),
|
||||
})?;
|
||||
|
||||
let title = re_res
|
||||
.name("title")
|
||||
.ok_or(Error::InvalidChapterTitle {
|
||||
@@ -310,6 +328,7 @@ impl BoxnovelSource {
|
||||
url: book.origin_book_url.clone(),
|
||||
})?
|
||||
.as_str()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let origin_chapter_url = element
|
||||
|
||||
@@ -45,7 +45,7 @@ impl Source for BoxnovelSource {
|
||||
|
||||
fn get_full_chapter(
|
||||
&self,
|
||||
stub: &lib_core::model::chapter::ChapterStub,
|
||||
stub: &lib_core::model::chapter::Chapter,
|
||||
) -> lib_scraper::Result<lib_core::model::chapter::ChapterForCreate> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ lib-components = { path = "../../libs/lib-components"}
|
||||
# -- Async
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
reqwest = "0.12.9"
|
||||
reqwest = { version = "0.12.9", features = ["json"] }
|
||||
# -- Json
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -25,14 +25,15 @@ tower-cookies = "0.10"
|
||||
# -- Tracing
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# -- Others
|
||||
time = "0.3"
|
||||
uuid = {version = "1", features = ["v4","fast-rng",]}
|
||||
strum_macros = "0.25"
|
||||
derive_more = {version = "1.0.0-beta", features = ["from"] }
|
||||
url = "2.5.3"
|
||||
mime_guess = "2.0.5"
|
||||
maud = { version = "0.26.0", features = ["axum"] }
|
||||
futures = "0.3.31"
|
||||
time = "0.3"
|
||||
ammonia = "4.0.0"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Api {
|
||||
pub client: reqwest::Client,
|
||||
pub url: url::Url,
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn new(url: url::Url) -> Self {
|
||||
Self {
|
||||
url,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ mod config;
|
||||
mod error;
|
||||
mod log;
|
||||
mod web;
|
||||
mod api;
|
||||
|
||||
pub use self::error::{Error, Result};
|
||||
use url::Url;
|
||||
@@ -36,7 +35,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Initialize ModelManager.
|
||||
// let mm = ModelManager::new().await?;
|
||||
let api = api::Api::new(Url::parse("http://localhost:3000/api/rpc").unwrap());
|
||||
let api = web::api::Api::new(Url::parse("http://localhost:3000/api/rpc").unwrap());
|
||||
|
||||
// -- Define Routes
|
||||
let app_state = AppState { api: api.clone() };
|
||||
@@ -55,7 +54,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
// region: --- Start Server
|
||||
// Note: For this block, ok to unwrap.
|
||||
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
|
||||
let listener = TcpListener::bind("0.0.0.0:8080").await.unwrap();
|
||||
info!("{:<12} - {:?}\n", "LISTENING", listener.local_addr());
|
||||
axum::serve(listener, routes_all.into_make_service())
|
||||
.await
|
||||
@@ -67,5 +66,5 @@ async fn main() -> Result<()> {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub api: api::Api,
|
||||
pub api: web::api::Api,
|
||||
}
|
||||
194
crates/services/web-frontend/src/web/api.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use lib_core::model::{book::{Book, BookFilter}, chapter::{Chapter, ChapterStub}, origin::Origin};
|
||||
use lib_utils::rpc_objects::RpcResponse;
|
||||
use serde_json::json;
|
||||
use tracing_subscriber::field::debug;
|
||||
|
||||
use crate::web::error::{Result, Error};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Api {
|
||||
pub client: reqwest::Client,
|
||||
pub url: url::Url,
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn new(url: url::Url) -> Self {
|
||||
Self {
|
||||
url,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub async fn get_book_page(&self, page_number: i64, page_size: i64, title: String) -> Result<Vec<Book>> {
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "list_books",
|
||||
"params": {
|
||||
"filters": {
|
||||
"title": {
|
||||
"$contains": title
|
||||
}
|
||||
},
|
||||
"list_options": {
|
||||
"offset": (page_number - 1) * page_size,
|
||||
"limit": page_size + 1,
|
||||
"order_bys": ["!updated_at"]
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
});
|
||||
let res = self.client.post(self.clone().url).json(
|
||||
&body
|
||||
).send().await?.json::<RpcResponse<_>>().await?;
|
||||
|
||||
res.error.map(|e| Err::<Vec<Book>, _>(Error::ApiError(e))).transpose()?;
|
||||
|
||||
Ok(res.result.ok_or(Error::ApiNoResult)?)
|
||||
}
|
||||
|
||||
pub async fn get_chapter_stubs_limited(&self, book_id: i64, limit: i64) -> Result<Vec<ChapterStub>> {
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "list_chapter_stubs",
|
||||
"params": {
|
||||
"filters": {
|
||||
"book_id": book_id
|
||||
},
|
||||
"list_options": {
|
||||
"order_bys": ["!volume", "!number"],
|
||||
"limit": limit
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
});
|
||||
let res = self.client.post(self.clone().url).json(
|
||||
&body
|
||||
).send().await?.json::<RpcResponse<_>>().await?;
|
||||
|
||||
res.error.map(|e| Err::<Vec<ChapterStub>, _>(Error::ApiError(e))).transpose()?;
|
||||
|
||||
Ok(res.result.ok_or(Error::ApiNoResult)?)
|
||||
}
|
||||
|
||||
pub async fn get_book(&self, book_id: i64) -> Result<Book> {
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "get_book",
|
||||
"params": {
|
||||
"id": book_id
|
||||
},
|
||||
"id": 1
|
||||
});
|
||||
let res = self.client.post(self.clone().url).json(
|
||||
&body
|
||||
).send().await?.json::<RpcResponse<_>>().await?;
|
||||
|
||||
res.error.map(|e| Err::<Book, _>(Error::ApiError(e))).transpose()?;
|
||||
|
||||
let mut book: Book = res.result.ok_or(Error::ApiNoResult)?;
|
||||
|
||||
book.summary = ammonia::clean(&book.summary);
|
||||
|
||||
Ok(book)
|
||||
}
|
||||
|
||||
pub async fn get_origin(&self, origin_id: i64) -> Result<Origin> {
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "get_origin",
|
||||
"params": {
|
||||
"id": origin_id
|
||||
},
|
||||
"id": 1
|
||||
});
|
||||
let res = self.client.post(self.clone().url).json(
|
||||
&body
|
||||
).send().await?.json::<RpcResponse<_>>().await?;
|
||||
|
||||
res.error.map(|e| Err::<Origin, _>(Error::ApiError(e))).transpose()?;
|
||||
|
||||
Ok(res.result.ok_or(Error::ApiNoResult)?)
|
||||
}
|
||||
|
||||
pub async fn get_chapter_stubs_for_book(&self, book_id: i64) -> Result<Vec<ChapterStub>> {
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "list_chapter_stubs",
|
||||
"params": {
|
||||
"filters": {
|
||||
"book_id": book_id
|
||||
},
|
||||
"list_options": {
|
||||
"order_bys": ["!volume", "!number"]
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
});
|
||||
let res = self.client.post(self.clone().url).json(
|
||||
&body
|
||||
).send().await?.json::<RpcResponse<_>>().await?;
|
||||
|
||||
res.error.map(|e| Err::<Vec<ChapterStub>, _>(Error::ApiError(e))).transpose()?;
|
||||
|
||||
let stubs = res.result.ok_or(Error::ApiNoResult)?;
|
||||
|
||||
Ok(stubs)
|
||||
}
|
||||
|
||||
pub async fn get_chapter_stub_for_book(&self, book_id: i64, chapter_number: i64) -> Result<Option<ChapterStub>> {
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "list_chapter_stubs",
|
||||
"params": {
|
||||
"filters": {
|
||||
"book_id": book_id,
|
||||
"number": chapter_number
|
||||
},
|
||||
"list_options": {
|
||||
"limit": 1
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
});
|
||||
let res = self.client.post(self.clone().url).json(
|
||||
&body
|
||||
).send().await?.json::<RpcResponse<Vec<_>>>().await?;
|
||||
|
||||
res.error.map(|e| Err::<ChapterStub, _>(Error::ApiError(e))).transpose()?;
|
||||
|
||||
let chapter = res.result.ok_or(Error::ApiNoResult)?.first().cloned();
|
||||
|
||||
Ok(chapter)
|
||||
}
|
||||
|
||||
pub async fn get_chapter_for_book(&self, book_id: i64, chapter_number: i64) -> Result<Option<Chapter>> {
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "list_chapters",
|
||||
"params": {
|
||||
"filters": {
|
||||
"book_id": book_id,
|
||||
"number": chapter_number
|
||||
},
|
||||
"list_options": {
|
||||
"limit": 1
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
});
|
||||
let res = self.client.post(self.clone().url).json(
|
||||
&body
|
||||
).send().await?.json::<RpcResponse<Vec<_>>>().await?;
|
||||
|
||||
res.error.map(|e| Err::<Chapter, _>(Error::ApiError(e))).transpose()?;
|
||||
|
||||
let chapter: Option<Chapter> = res.result.ok_or(Error::ApiNoResult)?.first().cloned().map(|mut c: Chapter| {
|
||||
c.content = ammonia::clean(&c.content);
|
||||
c
|
||||
});
|
||||
|
||||
Ok(chapter)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ use crate::web;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use derive_more::From;
|
||||
use futures::TryStreamExt;
|
||||
use lib_auth::{pwd, token};
|
||||
use lib_components::base::Base;
|
||||
use lib_components::notification::{Level, Notification};
|
||||
use lib_core::model::{self};
|
||||
use maud::Render;
|
||||
@@ -26,6 +28,17 @@ pub enum Error {
|
||||
user_id: i64,
|
||||
},
|
||||
|
||||
// - Web
|
||||
PageNotExists,
|
||||
|
||||
// -- Reqwest
|
||||
#[from]
|
||||
Reqwest(#[serde_as(as = "DisplayFromStr")] reqwest::Error),
|
||||
|
||||
// ApiNoResult,
|
||||
ApiError(lib_utils::rpc_objects::RpcError),
|
||||
ApiNoResult,
|
||||
|
||||
// -- CtxExtError
|
||||
#[from]
|
||||
CtxExt(web::mw_auth::CtxExtError),
|
||||
@@ -159,6 +172,22 @@ impl Error {
|
||||
v,
|
||||
))) => ClientError::INVALID_FORMAT(v.to_string()),
|
||||
|
||||
Reqwest(e) => {
|
||||
ClientError::CONNECTION_ERROR(e.to_string())
|
||||
}
|
||||
|
||||
ApiNoResult => {
|
||||
ClientError::API_ERROR("No result".to_string())
|
||||
}
|
||||
|
||||
ApiError(e) => {
|
||||
ClientError::API_ERROR(format!("code: {} msg: {}", e.code, e.message))
|
||||
}
|
||||
|
||||
PageNotExists => {
|
||||
ClientError::PageNotExists
|
||||
}
|
||||
|
||||
// -- Fallback.
|
||||
// ReqStampNotInResponseExt | Pwd(_) | Token(_) | SerdeJson(_) | Rpc(_) => (
|
||||
// StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -178,13 +207,25 @@ pub enum ClientError {
|
||||
INVALID_FORMAT(String),
|
||||
ENTITY_NOT_FOUND { entity: &'static str, id: i64 },
|
||||
|
||||
CONNECTION_ERROR(String),
|
||||
API_ERROR(String),
|
||||
|
||||
SERVICE_ERROR,
|
||||
TEST,
|
||||
|
||||
PageNotExists,
|
||||
}
|
||||
|
||||
// impl ClientError {
|
||||
// pub fn into_htmx_response
|
||||
// }
|
||||
|
||||
impl IntoResponse for ClientError {
|
||||
fn into_response(self) -> Response {
|
||||
let notification = match self {
|
||||
ClientError::PageNotExists => {
|
||||
Notification::new("Page not found", Level::Error)
|
||||
}
|
||||
ClientError::LOGIN_FAIL => {
|
||||
Notification::new("Login failed", Level::Error)
|
||||
}
|
||||
@@ -203,9 +244,17 @@ impl IntoResponse for ClientError {
|
||||
Notification::new("Service error", Level::Error)
|
||||
}
|
||||
ClientError::TEST => Notification::new("Test", Level::Error),
|
||||
|
||||
ClientError::CONNECTION_ERROR(msg) => {
|
||||
Notification::new_with_msg("Connection error", Level::Error, msg)
|
||||
}
|
||||
|
||||
ClientError::API_ERROR(msg) => {
|
||||
Notification::new_with_msg("API error", Level::Error, msg)
|
||||
}
|
||||
};
|
||||
|
||||
notification.render().into_response()
|
||||
Base::new_notification(notification).render().into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// region: --- Modules
|
||||
|
||||
mod error;
|
||||
pub mod api;
|
||||
pub mod mw_auth;
|
||||
pub mod mw_res_map;
|
||||
pub mod mw_stamp;
|
||||
|
||||
71
crates/services/web-frontend/src/web/routes_frontend/book.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{extract::{State, Path}, response::Response};
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
use futures::{join, try_join};
|
||||
use maud::Render;
|
||||
|
||||
use lib_components::pages::book as book_comp;
|
||||
use lib_components::pages::chapter as chapter_comp;
|
||||
use serde::Deserialize;
|
||||
use tracing_subscriber::field::debug;
|
||||
|
||||
use crate::web::{Error, Result};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/:book_id", get(get_book))
|
||||
.route("/:book_id/chapters/:chapter_number", get(get_chapter))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChapterPath {
|
||||
book_id: i64,
|
||||
chapter_number: i64,
|
||||
}
|
||||
|
||||
async fn get_chapter(State(state): State<AppState>, Path(args): Path<ChapterPath>) -> Result<Response> {
|
||||
let (chapter_prev, chapter_opt, chapter_next) = try_join!(
|
||||
state.api.get_chapter_stub_for_book(args.book_id, args.chapter_number - 1),
|
||||
state.api.get_chapter_for_book(args.book_id, args.chapter_number),
|
||||
state.api.get_chapter_stub_for_book(args.book_id, args.chapter_number + 1)
|
||||
)?;
|
||||
|
||||
let chapter = chapter_opt.ok_or_else(|| Error::PageNotExists)?;
|
||||
|
||||
let book = match state.api.get_book(args.book_id).await {
|
||||
Ok(books) => books,
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(chapter_comp::ChapterPage::new(book, chapter, chapter_prev, chapter_next).render().into_response())
|
||||
}
|
||||
|
||||
async fn get_book(State(state): State<AppState>, Path(book_id): Path<i64>) -> Response {
|
||||
let book = match state.api.get_book(book_id).await {
|
||||
Ok(books) => books,
|
||||
Err(e) => {
|
||||
return e.client_error().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let chapter_stubs = match state.api.get_chapter_stubs_for_book(book_id).await {
|
||||
Ok(chapter_stubs) => chapter_stubs,
|
||||
Err(e) => {
|
||||
return e.client_error().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let origin = match state.api.get_origin(book.origin_id).await {
|
||||
Ok(origin) => origin,
|
||||
Err(e) => {
|
||||
return e.client_error().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
book_comp::BookPage::new(book , chapter_stubs, origin).render().into_response()
|
||||
}
|
||||
@@ -1,17 +1,47 @@
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{extract::State, response::Response};
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
|
||||
use lib_components::base::Base;
|
||||
use futures::future::join_all;
|
||||
use maud::Render;
|
||||
|
||||
pub fn routes() -> Router {
|
||||
use lib_components::pages::index as index_comp;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::web::error::Result;
|
||||
|
||||
pub fn routes(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(get_index))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn get_index() -> impl IntoResponse {
|
||||
Base::new("Test").render()
|
||||
async fn get_index(State(state): State<AppState>) -> Response {
|
||||
let books = match state.api.get_book_page(1, 50, "".into()).await {
|
||||
Ok(books) => books,
|
||||
Err(e) => {
|
||||
return e.client_error().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let book_agg_tasks = books.iter().map(|b| state.api.get_chapter_stubs_limited(b.id, 3)).collect::<Vec<_>>();
|
||||
|
||||
let book_agg_results = match join_all(book_agg_tasks).await.into_iter().collect::<Result<Vec<_>>>() {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
return e.client_error().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let books_agg = books
|
||||
.into_iter()
|
||||
.zip(book_agg_results)
|
||||
.map(|(b, r)| {
|
||||
(b, r)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
index_comp::IndexPage::new(books_agg).render().into_response()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod index;
|
||||
mod book;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
@@ -15,8 +16,9 @@ pub struct HttpInfo {
|
||||
}
|
||||
|
||||
// Axum router for '/api/rpc'
|
||||
pub fn routes(app_state: crate::AppState) -> Router {
|
||||
pub fn routes(state: crate::AppState) -> Router {
|
||||
Router::new()
|
||||
.nest("/", index::routes())
|
||||
.nest("/", index::routes(state.clone()))
|
||||
.nest("/books", book::routes(state.clone()))
|
||||
// .with_state(app_state) // no app state needed yet
|
||||
}
|
||||
|
||||
26
test.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
total_time=0
|
||||
min_time=99999
|
||||
max_time=0
|
||||
num_requests=1000
|
||||
|
||||
for i in $(seq $num_requests);
|
||||
do
|
||||
request_time=$(curl -s -o /dev/null -w "%{time_total}" "http://localhost:8080")
|
||||
total_time=$(echo "$total_time + $request_time * 1000" | bc)
|
||||
|
||||
if (( $(echo "$request_time < $min_time" | bc -l) )); then
|
||||
min_time=$request_time
|
||||
fi
|
||||
|
||||
if (( $(echo "$request_time > $max_time" | bc -l) )); then
|
||||
max_time=$request_time
|
||||
fi
|
||||
done
|
||||
|
||||
average_time=$(echo "$total_time / $num_requests" | bc -l)
|
||||
min_time=$(echo "$min_time * 1000" | bc -l)
|
||||
max_time=$(echo "$max_time * 1000" | bc -l)
|
||||
|
||||
echo "Average Time: $average_time ms"
|
||||
echo "Minimum Time: $min_time ms"
|
||||
echo "Maximum Time: $max_time ms"
|
||||