mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 07:07:38 +03:00
Compare commits
6 Commits
04ad4e5aa4
...
062d69dc2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
062d69dc2a | ||
|
|
5e9626eb2a | ||
|
|
00e83af4e8 | ||
|
|
39080c0e51 | ||
|
|
9e59a5106b | ||
|
|
a922e553ed |
@ -7,9 +7,9 @@
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 8;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint16_t) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint32_t);
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 9;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint32_t);
|
||||
} // namespace
|
||||
|
||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||
@ -30,19 +30,21 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||
}
|
||||
|
||||
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight) {
|
||||
if (!file) {
|
||||
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis());
|
||||
return;
|
||||
}
|
||||
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
||||
sizeof(extraParagraphSpacing) + sizeof(viewportWidth) + sizeof(viewportHeight) +
|
||||
sizeof(pageCount) + sizeof(uint32_t),
|
||||
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
||||
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(uint32_t),
|
||||
"Header size mismatch");
|
||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||
serialization::writePod(file, fontId);
|
||||
serialization::writePod(file, lineCompression);
|
||||
serialization::writePod(file, extraParagraphSpacing);
|
||||
serialization::writePod(file, paragraphAlignment);
|
||||
serialization::writePod(file, viewportWidth);
|
||||
serialization::writePod(file, viewportHeight);
|
||||
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
|
||||
@ -50,7 +52,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
||||
}
|
||||
|
||||
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight) {
|
||||
if (!SdMan.openFileForRead("SCT", filePath, file)) {
|
||||
return false;
|
||||
}
|
||||
@ -70,15 +73,17 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
uint16_t fileViewportWidth, fileViewportHeight;
|
||||
float fileLineCompression;
|
||||
bool fileExtraParagraphSpacing;
|
||||
uint8_t fileParagraphAlignment;
|
||||
serialization::readPod(file, fileFontId);
|
||||
serialization::readPod(file, fileLineCompression);
|
||||
serialization::readPod(file, fileExtraParagraphSpacing);
|
||||
serialization::readPod(file, fileParagraphAlignment);
|
||||
serialization::readPod(file, fileViewportWidth);
|
||||
serialization::readPod(file, fileViewportHeight);
|
||||
|
||||
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
||||
extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth ||
|
||||
viewportHeight != fileViewportHeight) {
|
||||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
|
||||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight) {
|
||||
file.close();
|
||||
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
|
||||
clearCache();
|
||||
@ -109,8 +114,8 @@ bool Section::clearCache() const {
|
||||
}
|
||||
|
||||
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint16_t viewportWidth, const uint16_t viewportHeight,
|
||||
const std::function<void()>& progressSetupFn,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const std::function<void()>& progressSetupFn,
|
||||
const std::function<void(int)>& progressFn) {
|
||||
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||
@ -166,11 +171,13 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
|
||||
return false;
|
||||
}
|
||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight);
|
||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight);
|
||||
std::vector<uint32_t> lut = {};
|
||||
|
||||
ChapterHtmlSlimParser visitor(
|
||||
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight,
|
||||
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight,
|
||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
||||
progressFn);
|
||||
success = visitor.parseAndBuildPages();
|
||||
|
||||
@ -14,8 +14,8 @@ class Section {
|
||||
std::string filePath;
|
||||
FsFile file;
|
||||
|
||||
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth,
|
||||
uint16_t viewportHeight);
|
||||
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight);
|
||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||
|
||||
public:
|
||||
@ -28,11 +28,12 @@ class Section {
|
||||
renderer(renderer),
|
||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||
~Section() = default;
|
||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth,
|
||||
uint16_t viewportHeight);
|
||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight);
|
||||
bool clearCache() const;
|
||||
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth,
|
||||
uint16_t viewportHeight, const std::function<void()>& progressSetupFn = nullptr,
|
||||
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight,
|
||||
const std::function<void()>& progressSetupFn = nullptr,
|
||||
const std::function<void(int)>& progressFn = nullptr);
|
||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||
};
|
||||
|
||||
@ -97,7 +97,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
if (strcmp(name, "br") == 0) {
|
||||
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
||||
} else {
|
||||
self->startNewTextBlock(TextBlock::JUSTIFIED);
|
||||
self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment);
|
||||
}
|
||||
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||
@ -137,6 +137,21 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip soft-hyphen with UTF-8 representation (U+00AD) = 0xC2 0xAD
|
||||
const XML_Char SHY_BYTE_1 = static_cast<XML_Char>(0xC2);
|
||||
const XML_Char SHY_BYTE_2 = static_cast<XML_Char>(0xAD);
|
||||
// 1. Check for the start of the 2-byte Soft Hyphen sequence
|
||||
if (s[i] == SHY_BYTE_1) {
|
||||
// 2. Check if the next byte exists AND if it completes the sequence
|
||||
// We must check i + 1 < len to prevent reading past the end of the buffer.
|
||||
if ((i + 1 < len) && (s[i + 1] == SHY_BYTE_2)) {
|
||||
// Sequence 0xC2 0xAD found!
|
||||
// Skip the current byte (0xC2) and the next byte (0xAD)
|
||||
i++; // Increment 'i' one more time to skip the 0xAD byte
|
||||
continue; // Skip the rest of the loop and move to the next iteration
|
||||
}
|
||||
}
|
||||
|
||||
// If we're about to run out of space, then cut the word off and start a new one
|
||||
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
|
||||
self->partWordBuffer[self->partWordBufferIndex] = '\0';
|
||||
@ -206,7 +221,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
startNewTextBlock(TextBlock::JUSTIFIED);
|
||||
startNewTextBlock((TextBlock::Style)this->paragraphAlignment);
|
||||
|
||||
const XML_Parser parser = XML_ParserCreate(nullptr);
|
||||
int done;
|
||||
|
||||
@ -33,6 +33,7 @@ class ChapterHtmlSlimParser {
|
||||
int fontId;
|
||||
float lineCompression;
|
||||
bool extraParagraphSpacing;
|
||||
uint8_t paragraphAlignment;
|
||||
uint16_t viewportWidth;
|
||||
uint16_t viewportHeight;
|
||||
|
||||
@ -46,7 +47,8 @@ class ChapterHtmlSlimParser {
|
||||
public:
|
||||
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
|
||||
const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint16_t viewportWidth, const uint16_t viewportHeight,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight,
|
||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
||||
const std::function<void(int)>& progressFn = nullptr)
|
||||
: filepath(filepath),
|
||||
@ -54,6 +56,7 @@ class ChapterHtmlSlimParser {
|
||||
fontId(fontId),
|
||||
lineCompression(lineCompression),
|
||||
extraParagraphSpacing(extraParagraphSpacing),
|
||||
paragraphAlignment(paragraphAlignment),
|
||||
viewportWidth(viewportWidth),
|
||||
viewportHeight(viewportHeight),
|
||||
completePageFn(completePageFn),
|
||||
|
||||
@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance;
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
// Increment this when adding new persisted settings fields
|
||||
constexpr uint8_t SETTINGS_COUNT = 10;
|
||||
constexpr uint8_t SETTINGS_COUNT = 11;
|
||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||
} // namespace
|
||||
|
||||
@ -37,6 +37,7 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writePod(outputFile, fontFamily);
|
||||
serialization::writePod(outputFile, fontSize);
|
||||
serialization::writePod(outputFile, lineSpacing);
|
||||
serialization::writePod(outputFile, paragraphAlignment);
|
||||
outputFile.close();
|
||||
|
||||
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
||||
@ -83,6 +84,8 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, lineSpacing);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, paragraphAlignment);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
inputFile.close();
|
||||
|
||||
@ -43,6 +43,7 @@ class CrossPointSettings {
|
||||
// Font size options
|
||||
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
|
||||
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
|
||||
enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 };
|
||||
|
||||
// Sleep screen settings
|
||||
uint8_t sleepScreen = DARK;
|
||||
@ -62,6 +63,7 @@ class CrossPointSettings {
|
||||
uint8_t fontFamily = BOOKERLY;
|
||||
uint8_t fontSize = MEDIUM;
|
||||
uint8_t lineSpacing = NORMAL;
|
||||
uint8_t paragraphAlignment = JUSTIFIED;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
|
||||
@ -22,4 +22,5 @@ class Activity {
|
||||
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); }
|
||||
virtual void loop() {}
|
||||
virtual bool skipLoopDelay() { return false; }
|
||||
virtual bool preventAutoSleep() { return false; }
|
||||
};
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
#include <SDCardManager.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "fontIds.h"
|
||||
@ -42,16 +40,16 @@ void SleepActivity::onEnter() {
|
||||
}
|
||||
|
||||
void SleepActivity::renderPopup(const char* message) const {
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message);
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
|
||||
constexpr int margin = 20;
|
||||
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
|
||||
constexpr int y = 117;
|
||||
const int w = textWidth + margin * 2;
|
||||
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
|
||||
// renderer.clearScreen();
|
||||
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
|
||||
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
|
||||
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message);
|
||||
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
|
||||
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
|
||||
@ -70,4 +70,5 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
|
||||
bool preventAutoSleep() override { return webServer && webServer->isRunning(); }
|
||||
};
|
||||
|
||||
@ -447,7 +447,16 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi
|
||||
|
||||
void WifiSelectionActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
// If a subactivity is active, yield CPU time but don't render
|
||||
if (subActivity) {
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't render if we're in PASSWORD_ENTRY state - we're just transitioning
|
||||
// from the keyboard subactivity back to the main activity
|
||||
if (state == WifiSelectionState::PASSWORD_ENTRY) {
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -267,7 +267,8 @@ void EpubReaderActivity::renderScreen() {
|
||||
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
|
||||
|
||||
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, viewportWidth, viewportHeight)) {
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight)) {
|
||||
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
|
||||
|
||||
// Progress bar dimensions
|
||||
@ -311,8 +312,8 @@ void EpubReaderActivity::renderScreen() {
|
||||
};
|
||||
|
||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, viewportWidth, viewportHeight, progressSetup,
|
||||
progressCallback)) {
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, progressSetup, progressCallback)) {
|
||||
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
|
||||
section.reset();
|
||||
return;
|
||||
|
||||
@ -41,4 +41,5 @@ class OtaUpdateActivity : public ActivityWithSubactivity {
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
bool preventAutoSleep() override { return state == CHECKING_FOR_UPDATE || state == UPDATE_IN_PROGRESS; }
|
||||
};
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
// Define the static settings list
|
||||
namespace {
|
||||
constexpr int settingsCount = 11;
|
||||
constexpr int settingsCount = 12;
|
||||
const SettingInfo settingsList[settingsCount] = {
|
||||
// Should match with SLEEP_SCREEN_MODE
|
||||
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
|
||||
@ -34,6 +34,10 @@ const SettingInfo settingsList[settingsCount] = {
|
||||
{"Bookerly", "Noto Sans", "Open Dyslexic"}},
|
||||
{"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}},
|
||||
{"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}},
|
||||
{"Reader Paragraph Alignment",
|
||||
SettingType::ENUM,
|
||||
&CrossPointSettings::paragraphAlignment,
|
||||
{"Justify", "Left", "Center", "Right"}},
|
||||
{"Check for updates", SettingType::ACTION, nullptr, {}},
|
||||
};
|
||||
} // namespace
|
||||
|
||||
@ -316,9 +316,10 @@ void loop() {
|
||||
lastMemPrint = millis();
|
||||
}
|
||||
|
||||
// Check for any user activity (button press or release)
|
||||
// Check for any user activity (button press or release) or active background work
|
||||
static unsigned long lastActivityTime = millis();
|
||||
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) {
|
||||
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() ||
|
||||
(currentActivity && currentActivity->preventAutoSleep())) {
|
||||
lastActivityTime = millis(); // Reset inactivity timer
|
||||
}
|
||||
|
||||
|
||||
@ -341,6 +341,90 @@
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Failed uploads banner */
|
||||
.failed-uploads-banner {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
display: none;
|
||||
}
|
||||
.failed-uploads-banner.show {
|
||||
display: block;
|
||||
}
|
||||
.failed-uploads-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.failed-uploads-title {
|
||||
font-weight: 600;
|
||||
color: #856404;
|
||||
margin: 0;
|
||||
}
|
||||
.dismiss-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
color: #856404;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.dismiss-btn:hover {
|
||||
color: #533f03;
|
||||
}
|
||||
.failed-file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #ffe69c;
|
||||
}
|
||||
.failed-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.failed-file-info {
|
||||
flex: 1;
|
||||
}
|
||||
.failed-file-name {
|
||||
font-weight: 500;
|
||||
color: #856404;
|
||||
}
|
||||
.failed-file-error {
|
||||
font-size: 0.85em;
|
||||
color: #856404;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.retry-btn {
|
||||
background-color: #ffc107;
|
||||
color: #533f03;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.retry-btn:hover {
|
||||
background-color: #e0a800;
|
||||
}
|
||||
.retry-all-btn {
|
||||
background-color: #ffc107;
|
||||
color: #533f03;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.retry-all-btn:hover {
|
||||
background-color: #e0a800;
|
||||
}
|
||||
/* Delete modal */
|
||||
.delete-warning {
|
||||
color: #e74c3c;
|
||||
@ -505,6 +589,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed Uploads Banner -->
|
||||
<div class="failed-uploads-banner" id="failedUploadsBanner">
|
||||
<div class="failed-uploads-header">
|
||||
<h3 class="failed-uploads-title">⚠️ Some files failed to upload</h3>
|
||||
<button class="dismiss-btn" onclick="dismissFailedUploads()" title="Dismiss">×</button>
|
||||
</div>
|
||||
<div id="failedFilesList"></div>
|
||||
<button class="retry-all-btn" onclick="retryAllFailedUploads()">Retry All Failed Uploads</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="contents-header">
|
||||
<h2 class="contents-title">Contents</h2>
|
||||
@ -531,7 +625,7 @@
|
||||
<h3>📤 Upload file</h3>
|
||||
<div class="upload-form">
|
||||
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
|
||||
<input type="file" id="fileInput" onchange="validateFile()">
|
||||
<input type="file" id="fileInput" onchange="validateFile()" multiple>
|
||||
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
|
||||
<div id="progress-container">
|
||||
<div id="progress-bar"><div id="progress-fill"></div></div>
|
||||
@ -717,65 +811,183 @@
|
||||
function validateFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const file = fileInput.files[0];
|
||||
uploadBtn.disabled = !file;
|
||||
const files = fileInput.files;
|
||||
uploadBtn.disabled = !(files.length > 0);
|
||||
}
|
||||
|
||||
function uploadFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const file = fileInput.files[0];
|
||||
let failedUploadsGlobal = [];
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first!');
|
||||
function uploadFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const files = Array.from(fileInput.files);
|
||||
|
||||
if (files.length === 0) {
|
||||
alert('Please select at least one file!');
|
||||
return;
|
||||
}
|
||||
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
|
||||
progressContainer.style.display = 'block';
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
let currentIndex = 0;
|
||||
const failedFiles = [];
|
||||
|
||||
function uploadNextFile() {
|
||||
if (currentIndex >= files.length) {
|
||||
// All files processed - show summary
|
||||
if (failedFiles.length === 0) {
|
||||
progressFill.style.backgroundColor = '#4caf50';
|
||||
progressText.textContent = 'All uploads complete!';
|
||||
setTimeout(() => {
|
||||
closeUploadModal();
|
||||
hydrate(); // Refresh file list instead of reloading
|
||||
}, 1000);
|
||||
} else {
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
const failedList = failedFiles.map(f => f.name).join(', ');
|
||||
progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
|
||||
|
||||
// Store failed files globally and show banner
|
||||
failedUploadsGlobal = failedFiles;
|
||||
|
||||
setTimeout(() => {
|
||||
closeUploadModal();
|
||||
showFailedUploadsBanner();
|
||||
hydrate(); // Refresh file list to show successfully uploaded files
|
||||
}, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[currentIndex];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
|
||||
progressContainer.style.display = 'block';
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Include path as query parameter since multipart form data doesn't make
|
||||
// form fields available until after file upload completes
|
||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||||
|
||||
xhr.upload.onprogress = function(e) {
|
||||
progressFill.style.width = '0%';
|
||||
progressFill.style.backgroundColor = '#4caf50';
|
||||
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`;
|
||||
|
||||
xhr.upload.onprogress = function (e) {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
progressFill.style.width = percent + '%';
|
||||
progressText.textContent = 'Uploading: ' + percent + '%';
|
||||
progressText.textContent =
|
||||
`Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
progressText.textContent = 'Upload complete!';
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
currentIndex++;
|
||||
uploadNextFile(); // upload next file
|
||||
} else {
|
||||
progressText.textContent = 'Upload failed: ' + xhr.responseText;
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
uploadBtn.disabled = false;
|
||||
// Track failure and continue with next file
|
||||
failedFiles.push({ name: file.name, error: xhr.responseText, file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
progressText.textContent = 'Upload failed - network error';
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
uploadBtn.disabled = false;
|
||||
xhr.onerror = function () {
|
||||
// Track network error and continue with next file
|
||||
failedFiles.push({ name: file.name, error: 'network error', file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
uploadNextFile();
|
||||
}
|
||||
|
||||
function showFailedUploadsBanner() {
|
||||
const banner = document.getElementById('failedUploadsBanner');
|
||||
const filesList = document.getElementById('failedFilesList');
|
||||
|
||||
filesList.innerHTML = '';
|
||||
|
||||
failedUploadsGlobal.forEach((failedFile, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'failed-file-item';
|
||||
item.innerHTML = `
|
||||
<div class="failed-file-info">
|
||||
<div class="failed-file-name">📄 ${escapeHtml(failedFile.name)}</div>
|
||||
<div class="failed-file-error">Error: ${escapeHtml(failedFile.error)}</div>
|
||||
</div>
|
||||
<button class="retry-btn" onclick="retrySingleUpload(${index})">Retry</button>
|
||||
`;
|
||||
filesList.appendChild(item);
|
||||
});
|
||||
|
||||
// Ensure retry all button is visible
|
||||
const retryAllBtn = banner.querySelector('.retry-all-btn');
|
||||
if (retryAllBtn) retryAllBtn.style.display = '';
|
||||
|
||||
banner.classList.add('show');
|
||||
}
|
||||
|
||||
function dismissFailedUploads() {
|
||||
const banner = document.getElementById('failedUploadsBanner');
|
||||
banner.classList.remove('show');
|
||||
failedUploadsGlobal = [];
|
||||
}
|
||||
|
||||
function retrySingleUpload(index) {
|
||||
const failedFile = failedUploadsGlobal[index];
|
||||
if (!failedFile) return;
|
||||
|
||||
// Create a DataTransfer to set the file input
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(failedFile.file);
|
||||
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
fileInput.files = dt.files;
|
||||
|
||||
// Remove this file from failed list
|
||||
failedUploadsGlobal.splice(index, 1);
|
||||
|
||||
// If no more failed files, hide banner
|
||||
if (failedUploadsGlobal.length === 0) {
|
||||
dismissFailedUploads();
|
||||
}
|
||||
|
||||
// Open modal and trigger upload
|
||||
openUploadModal();
|
||||
validateFile();
|
||||
}
|
||||
|
||||
function retryAllFailedUploads() {
|
||||
if (failedUploadsGlobal.length === 0) return;
|
||||
|
||||
// Create a DataTransfer with all failed files
|
||||
const dt = new DataTransfer();
|
||||
failedUploadsGlobal.forEach(failedFile => {
|
||||
dt.items.add(failedFile.file);
|
||||
});
|
||||
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
fileInput.files = dt.files;
|
||||
|
||||
// Clear failed files list
|
||||
failedUploadsGlobal = [];
|
||||
dismissFailedUploads();
|
||||
|
||||
// Open modal and trigger upload
|
||||
openUploadModal();
|
||||
validateFile();
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
const folderName = document.getElementById('folderName').value.trim();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user