This commit is contained in:
parent
6bbfc671b3
commit
2e376269c2
28 changed files with 389 additions and 332 deletions
|
|
@ -549,21 +549,21 @@ void AdminController::uploadStickers(const HttpRequestPtr &req,
|
||||||
file.saveAs(fullPath);
|
file.saveAs(fullPath);
|
||||||
validCount++;
|
validCount++;
|
||||||
|
|
||||||
// Insert into database
|
// Insert into database (synchronous to ensure completion before response)
|
||||||
*dbClient << "INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id"
|
try {
|
||||||
<< stickerName << filePath
|
auto result = dbClient->execSqlSync(
|
||||||
>> [uploaded, stickerName, filePath](const Result& r) mutable {
|
"INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id",
|
||||||
if (!r.empty()) {
|
stickerName, filePath);
|
||||||
|
if (!result.empty()) {
|
||||||
Json::Value sticker;
|
Json::Value sticker;
|
||||||
sticker["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
sticker["id"] = static_cast<Json::Int64>(result[0]["id"].as<int64_t>());
|
||||||
sticker["name"] = stickerName;
|
sticker["name"] = stickerName;
|
||||||
sticker["filePath"] = filePath;
|
sticker["filePath"] = filePath;
|
||||||
uploaded.append(sticker);
|
uploaded.append(sticker);
|
||||||
}
|
}
|
||||||
}
|
} catch (const DrogonDbException& e) {
|
||||||
>> [](const DrogonDbException& e) {
|
|
||||||
LOG_ERROR << "Failed to insert sticker: " << e.base().what();
|
LOG_ERROR << "Failed to insert sticker: " << e.base().what();
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validCount == 0) {
|
if (validCount == 0) {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,33 @@ namespace {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build minimal audio JSON (for realm listings without user info)
|
||||||
|
Json::Value buildAudioJsonMinimal(const drogon::orm::Row& row) {
|
||||||
|
Json::Value audio;
|
||||||
|
audio["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||||
|
audio["title"] = row["title"].as<std::string>();
|
||||||
|
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||||
|
audio["filePath"] = row["file_path"].as<std::string>();
|
||||||
|
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
||||||
|
audio["durationSeconds"] = row["duration_seconds"].as<int>();
|
||||||
|
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
|
||||||
|
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
||||||
|
audio["playCount"] = row["play_count"].as<int>();
|
||||||
|
audio["createdAt"] = row["created_at"].as<std::string>();
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build audio JSON with user info (for public listings)
|
||||||
|
Json::Value buildAudioJsonWithUser(const drogon::orm::Row& row) {
|
||||||
|
Json::Value audio = buildAudioJsonMinimal(row);
|
||||||
|
audio["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
||||||
|
audio["username"] = row["username"].as<std::string>();
|
||||||
|
audio["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||||
|
audio["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||||
|
audio["realmName"] = row["realm_name"].as<std::string>();
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
|
||||||
// Process audio metadata asynchronously
|
// Process audio metadata asynchronously
|
||||||
void processAudioMetadata(int64_t audioId, const std::string& audioFullPath, const std::string& format) {
|
void processAudioMetadata(int64_t audioId, const std::string& audioFullPath, const std::string& format) {
|
||||||
std::thread([audioId, audioFullPath, format]() {
|
std::thread([audioId, audioFullPath, format]() {
|
||||||
|
|
@ -131,27 +158,9 @@ void AudioController::getAllAudio(const HttpRequestPtr &req,
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
Json::Value audioFiles(Json::arrayValue);
|
Json::Value audioFiles(Json::arrayValue);
|
||||||
|
|
||||||
for (const auto& row : r) {
|
for (const auto& row : r) {
|
||||||
Json::Value audio;
|
audioFiles.append(buildAudioJsonWithUser(row));
|
||||||
audio["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
audio["title"] = row["title"].as<std::string>();
|
|
||||||
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
audio["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
|
||||||
audio["durationSeconds"] = row["duration_seconds"].as<int>();
|
|
||||||
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
|
|
||||||
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
|
||||||
audio["playCount"] = row["play_count"].as<int>();
|
|
||||||
audio["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
audio["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
|
||||||
audio["username"] = row["username"].as<std::string>();
|
|
||||||
audio["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
|
||||||
audio["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
|
||||||
audio["realmName"] = row["realm_name"].as<std::string>();
|
|
||||||
audioFiles.append(audio);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp["audio"] = audioFiles;
|
resp["audio"] = audioFiles;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -175,27 +184,9 @@ void AudioController::getLatestAudio(const HttpRequestPtr &,
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
Json::Value audioFiles(Json::arrayValue);
|
Json::Value audioFiles(Json::arrayValue);
|
||||||
|
|
||||||
for (const auto& row : r) {
|
for (const auto& row : r) {
|
||||||
Json::Value audio;
|
audioFiles.append(buildAudioJsonWithUser(row));
|
||||||
audio["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
audio["title"] = row["title"].as<std::string>();
|
|
||||||
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
audio["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
|
||||||
audio["durationSeconds"] = row["duration_seconds"].as<int>();
|
|
||||||
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
|
|
||||||
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
|
||||||
audio["playCount"] = row["play_count"].as<int>();
|
|
||||||
audio["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
audio["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
|
||||||
audio["username"] = row["username"].as<std::string>();
|
|
||||||
audio["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
|
||||||
audio["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
|
||||||
audio["realmName"] = row["realm_name"].as<std::string>();
|
|
||||||
audioFiles.append(audio);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp["audio"] = audioFiles;
|
resp["audio"] = audioFiles;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -312,20 +303,8 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req,
|
||||||
|
|
||||||
Json::Value audioFiles(Json::arrayValue);
|
Json::Value audioFiles(Json::arrayValue);
|
||||||
for (const auto& row : r) {
|
for (const auto& row : r) {
|
||||||
Json::Value audio;
|
audioFiles.append(buildAudioJsonMinimal(row));
|
||||||
audio["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
audio["title"] = row["title"].as<std::string>();
|
|
||||||
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
audio["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
|
||||||
audio["durationSeconds"] = row["duration_seconds"].as<int>();
|
|
||||||
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
|
|
||||||
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
|
||||||
audio["playCount"] = row["play_count"].as<int>();
|
|
||||||
audio["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
audioFiles.append(audio);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp["audio"] = audioFiles;
|
resp["audio"] = audioFiles;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -383,20 +362,8 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req,
|
||||||
|
|
||||||
Json::Value audioFiles(Json::arrayValue);
|
Json::Value audioFiles(Json::arrayValue);
|
||||||
for (const auto& row : r) {
|
for (const auto& row : r) {
|
||||||
Json::Value audio;
|
audioFiles.append(buildAudioJsonMinimal(row));
|
||||||
audio["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
audio["title"] = row["title"].as<std::string>();
|
|
||||||
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
audio["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
|
||||||
audio["durationSeconds"] = row["duration_seconds"].as<int>();
|
|
||||||
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
|
|
||||||
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
|
||||||
audio["playCount"] = row["play_count"].as<int>();
|
|
||||||
audio["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
audioFiles.append(audio);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp["audio"] = audioFiles;
|
resp["audio"] = audioFiles;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -537,18 +504,12 @@ void AudioController::uploadAudio(const HttpRequestPtr &req,
|
||||||
|
|
||||||
const auto& file = parser.getFiles()[0];
|
const auto& file = parser.getFiles()[0];
|
||||||
|
|
||||||
std::string title = parser.getParameter<std::string>("title");
|
std::string title = sanitizeUserInput(parser.getParameter<std::string>("title"), 255);
|
||||||
if (title.empty()) {
|
if (title.empty()) {
|
||||||
title = "Untitled Audio";
|
title = "Untitled Audio";
|
||||||
}
|
}
|
||||||
if (title.length() > 255) {
|
|
||||||
title = title.substr(0, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string description = parser.getParameter<std::string>("description");
|
std::string description = sanitizeUserInput(parser.getParameter<std::string>("description"), 5000);
|
||||||
if (description.length() > 5000) {
|
|
||||||
description = description.substr(0, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (500MB max)
|
// Validate file size (500MB max)
|
||||||
const size_t maxSize = 500 * 1024 * 1024;
|
const size_t maxSize = 500 * 1024 * 1024;
|
||||||
|
|
@ -721,12 +682,10 @@ void AudioController::updateAudio(const HttpRequestPtr &req,
|
||||||
std::string title, description;
|
std::string title, description;
|
||||||
|
|
||||||
if (json->isMember("title")) {
|
if (json->isMember("title")) {
|
||||||
title = (*json)["title"].asString();
|
title = sanitizeUserInput((*json)["title"].asString(), 255);
|
||||||
if (title.length() > 255) title = title.substr(0, 255);
|
|
||||||
}
|
}
|
||||||
if (json->isMember("description")) {
|
if (json->isMember("description")) {
|
||||||
description = (*json)["description"].asString();
|
description = sanitizeUserInput((*json)["description"].asString(), 5000);
|
||||||
if (description.length() > 5000) description = description.substr(0, 5000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json->isMember("title") && json->isMember("description")) {
|
if (json->isMember("title") && json->isMember("description")) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,34 @@ static constexpr size_t MAX_COVER_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
using namespace drogon::orm;
|
using namespace drogon::orm;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Build minimal ebook JSON (for realm listings without user info)
|
||||||
|
Json::Value buildEbookJsonMinimal(const drogon::orm::Row& row) {
|
||||||
|
Json::Value ebook;
|
||||||
|
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||||
|
ebook["title"] = row["title"].as<std::string>();
|
||||||
|
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||||
|
ebook["filePath"] = row["file_path"].as<std::string>();
|
||||||
|
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
||||||
|
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||||
|
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
||||||
|
ebook["readCount"] = row["read_count"].as<int>();
|
||||||
|
ebook["createdAt"] = row["created_at"].as<std::string>();
|
||||||
|
return ebook;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ebook JSON with user info (for public listings)
|
||||||
|
Json::Value buildEbookJsonWithUser(const drogon::orm::Row& row) {
|
||||||
|
Json::Value ebook = buildEbookJsonMinimal(row);
|
||||||
|
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
||||||
|
ebook["username"] = row["username"].as<std::string>();
|
||||||
|
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||||
|
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||||
|
ebook["realmName"] = row["realm_name"].as<std::string>();
|
||||||
|
return ebook;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void EbookController::getAllEbooks(const HttpRequestPtr &req,
|
void EbookController::getAllEbooks(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
auto pagination = parsePagination(req, 20, 50);
|
auto pagination = parsePagination(req, 20, 50);
|
||||||
|
|
@ -40,26 +68,9 @@ void EbookController::getAllEbooks(const HttpRequestPtr &req,
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
Json::Value ebooks(Json::arrayValue);
|
Json::Value ebooks(Json::arrayValue);
|
||||||
|
|
||||||
for (const auto& row : r) {
|
for (const auto& row : r) {
|
||||||
Json::Value ebook;
|
ebooks.append(buildEbookJsonWithUser(row));
|
||||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
ebook["title"] = row["title"].as<std::string>();
|
|
||||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
|
||||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
|
||||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
|
||||||
ebook["readCount"] = row["read_count"].as<int>();
|
|
||||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
|
||||||
ebook["username"] = row["username"].as<std::string>();
|
|
||||||
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
|
||||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
|
||||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
|
||||||
ebooks.append(ebook);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp["ebooks"] = ebooks;
|
resp["ebooks"] = ebooks;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -83,26 +94,9 @@ void EbookController::getLatestEbooks(const HttpRequestPtr &,
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
Json::Value ebooks(Json::arrayValue);
|
Json::Value ebooks(Json::arrayValue);
|
||||||
|
|
||||||
for (const auto& row : r) {
|
for (const auto& row : r) {
|
||||||
Json::Value ebook;
|
ebooks.append(buildEbookJsonWithUser(row));
|
||||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
ebook["title"] = row["title"].as<std::string>();
|
|
||||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
|
||||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
|
||||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
|
||||||
ebook["readCount"] = row["read_count"].as<int>();
|
|
||||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
|
||||||
ebook["username"] = row["username"].as<std::string>();
|
|
||||||
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
|
||||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
|
||||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
|
||||||
ebooks.append(ebook);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp["ebooks"] = ebooks;
|
resp["ebooks"] = ebooks;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +132,6 @@ void EbookController::getEbook(const HttpRequestPtr &,
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto& row = r[0];
|
const auto& row = r[0];
|
||||||
|
|
||||||
if (!row["is_public"].as<bool>()) {
|
if (!row["is_public"].as<bool>()) {
|
||||||
callback(jsonError("Ebook not found", k404NotFound));
|
callback(jsonError("Ebook not found", k404NotFound));
|
||||||
return;
|
return;
|
||||||
|
|
@ -146,22 +139,7 @@ void EbookController::getEbook(const HttpRequestPtr &,
|
||||||
|
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
auto& ebook = resp["ebook"];
|
resp["ebook"] = buildEbookJsonWithUser(row);
|
||||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
ebook["title"] = row["title"].as<std::string>();
|
|
||||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
|
||||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
|
||||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
|
||||||
ebook["readCount"] = row["read_count"].as<int>();
|
|
||||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
|
||||||
ebook["username"] = row["username"].as<std::string>();
|
|
||||||
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
|
||||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
|
||||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
|
||||||
|
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
>> DB_ERROR(callback, "get ebook");
|
>> DB_ERROR(callback, "get ebook");
|
||||||
|
|
@ -186,23 +164,12 @@ void EbookController::getUserEbooks(const HttpRequestPtr &,
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
resp["username"] = username;
|
resp["username"] = username;
|
||||||
Json::Value ebooks(Json::arrayValue);
|
Json::Value ebooks(Json::arrayValue);
|
||||||
|
|
||||||
for (const auto& row : r) {
|
for (const auto& row : r) {
|
||||||
Json::Value ebook;
|
Json::Value ebook = buildEbookJsonMinimal(row);
|
||||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
ebook["title"] = row["title"].as<std::string>();
|
|
||||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
|
||||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
|
||||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
|
||||||
ebook["readCount"] = row["read_count"].as<int>();
|
|
||||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
ebook["realmName"] = row["realm_name"].as<std::string>();
|
||||||
ebooks.append(ebook);
|
ebooks.append(ebook);
|
||||||
}
|
}
|
||||||
|
|
||||||
resp["ebooks"] = ebooks;
|
resp["ebooks"] = ebooks;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -259,19 +226,8 @@ void EbookController::getRealmEbooks(const HttpRequestPtr &,
|
||||||
// Ebooks
|
// Ebooks
|
||||||
Json::Value ebooks(Json::arrayValue);
|
Json::Value ebooks(Json::arrayValue);
|
||||||
for (const auto& row : r) {
|
for (const auto& row : r) {
|
||||||
Json::Value ebook;
|
ebooks.append(buildEbookJsonMinimal(row));
|
||||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
||||||
ebook["title"] = row["title"].as<std::string>();
|
|
||||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
||||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
|
||||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
|
||||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
|
||||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
|
||||||
ebook["readCount"] = row["read_count"].as<int>();
|
|
||||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
|
||||||
ebooks.append(ebook);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp["ebooks"] = ebooks;
|
resp["ebooks"] = ebooks;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include "../common/HttpHelpers.h"
|
#include "../common/HttpHelpers.h"
|
||||||
#include "../common/AuthHelpers.h"
|
#include "../common/AuthHelpers.h"
|
||||||
#include "../common/CryptoUtils.h"
|
#include "../common/CryptoUtils.h"
|
||||||
|
#include "../common/FileUtils.h"
|
||||||
#include <drogon/utils/Utilities.h>
|
#include <drogon/utils/Utilities.h>
|
||||||
#include <drogon/Cookie.h>
|
#include <drogon/Cookie.h>
|
||||||
#include <drogon/MultiPart.h>
|
#include <drogon/MultiPart.h>
|
||||||
|
|
@ -32,11 +33,25 @@ namespace {
|
||||||
return crypto_utils::bytesToHex(bytes, sizeof(bytes));
|
return crypto_utils::bytesToHex(bytes, sizeof(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool validateRealmName(const std::string& name) {
|
bool validateRealmSlug(const std::string& slug) {
|
||||||
|
if (slug.length() < 3 || slug.length() > 30) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return std::regex_match(slug, std::regex("^[a-z0-9-]+$"));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validateRealmDisplayName(const std::string& name) {
|
||||||
if (name.length() < 3 || name.length() > 30) {
|
if (name.length() < 3 || name.length() > 30) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return std::regex_match(name, std::regex("^[a-z0-9-]+$"));
|
// Allow uppercase letters, lowercase letters, numbers, and hyphens
|
||||||
|
return std::regex_match(name, std::regex("^[a-zA-Z0-9-]+$"));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string toSlug(const std::string& displayName) {
|
||||||
|
std::string slug = displayName;
|
||||||
|
std::transform(slug.begin(), slug.end(), slug.begin(), ::tolower);
|
||||||
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX #12: Safe file deletion with path traversal protection
|
// SECURITY FIX #12: Safe file deletion with path traversal protection
|
||||||
|
|
@ -154,7 +169,7 @@ void RealmController::getUserRealms(const HttpRequestPtr &req,
|
||||||
}
|
}
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT r.id, r.name, r.description, r.stream_key, r.realm_type, r.is_active, r.is_live, "
|
*dbClient << "SELECT r.id, r.name, r.display_name, r.description, r.stream_key, r.realm_type, r.is_active, r.is_live, "
|
||||||
"r.viewer_count, r.chat_enabled, r.chat_guests_allowed, r.chat_slow_mode_seconds, "
|
"r.viewer_count, r.chat_enabled, r.chat_guests_allowed, r.chat_slow_mode_seconds, "
|
||||||
"r.chat_retention_hours, r.offline_image_url, r.title_color, r.created_at, "
|
"r.chat_retention_hours, r.offline_image_url, r.title_color, r.created_at, "
|
||||||
"(SELECT COUNT(*) FROM videos v WHERE v.realm_id = r.id AND v.status = 'ready') as video_count, "
|
"(SELECT COUNT(*) FROM videos v WHERE v.realm_id = r.id AND v.status = 'ready') as video_count, "
|
||||||
|
|
@ -171,6 +186,8 @@ void RealmController::getUserRealms(const HttpRequestPtr &req,
|
||||||
Json::Value realm;
|
Json::Value realm;
|
||||||
realm["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
realm["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||||
realm["name"] = row["name"].as<std::string>();
|
realm["name"] = row["name"].as<std::string>();
|
||||||
|
// Use display_name if available, otherwise fall back to name
|
||||||
|
realm["displayName"] = row["display_name"].isNull() ? row["name"].as<std::string>() : row["display_name"].as<std::string>();
|
||||||
realm["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
realm["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||||
realm["type"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as<std::string>();
|
realm["type"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as<std::string>();
|
||||||
realm["streamKey"] = row["stream_key"].isNull() ? "" : row["stream_key"].as<std::string>();
|
realm["streamKey"] = row["stream_key"].isNull() ? "" : row["stream_key"].as<std::string>();
|
||||||
|
|
@ -210,11 +227,11 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string name = (*json)["name"].asString();
|
std::string displayName = (*json)["name"].asString();
|
||||||
std::string realmType = (*json).isMember("type") ? (*json)["type"].asString() : "stream";
|
std::string realmType = (*json).isMember("type") ? (*json)["type"].asString() : "stream";
|
||||||
|
|
||||||
// Check for censored words in realm name
|
// Check for censored words in realm name
|
||||||
if (CensorService::getInstance().containsCensoredWords(name)) {
|
if (CensorService::getInstance().containsCensoredWords(displayName)) {
|
||||||
callback(jsonError("Realm name contains prohibited content"));
|
callback(jsonError("Realm name contains prohibited content"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -225,16 +242,20 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateRealmName(name)) {
|
// Validate display name format (allows uppercase)
|
||||||
callback(jsonError("Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only"));
|
if (!validateRealmDisplayName(displayName)) {
|
||||||
|
callback(jsonError("Realm name must be 3-30 characters, letters, numbers, and hyphens only"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate lowercase slug for URL
|
||||||
|
std::string name = toSlug(displayName);
|
||||||
|
|
||||||
// Check permission based on realm type
|
// Check permission based on realm type
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT is_streamer, is_uploader, is_watch_creator FROM users WHERE id = $1"
|
*dbClient << "SELECT is_streamer, is_uploader, is_watch_creator FROM users WHERE id = $1"
|
||||||
<< user.id
|
<< user.id
|
||||||
>> [req, callback, user, dbClient, name, realmType](const Result& r) {
|
>> [req, callback, user, dbClient, name, displayName, realmType](const Result& r) {
|
||||||
if (r.empty()) {
|
if (r.empty()) {
|
||||||
callback(jsonError("User not found", k404NotFound));
|
callback(jsonError("User not found", k404NotFound));
|
||||||
return;
|
return;
|
||||||
|
|
@ -269,7 +290,7 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
// Check if realm name already exists
|
// Check if realm name already exists
|
||||||
*dbClient << "SELECT id FROM realms WHERE name = $1"
|
*dbClient << "SELECT id FROM realms WHERE name = $1"
|
||||||
<< name
|
<< name
|
||||||
>> [dbClient, user, name, realmType, callback](const Result& r2) {
|
>> [dbClient, user, name, displayName, realmType, callback](const Result& r2) {
|
||||||
if (!r2.empty()) {
|
if (!r2.empty()) {
|
||||||
callback(jsonError("Realm name already taken"));
|
callback(jsonError("Realm name already taken"));
|
||||||
return;
|
return;
|
||||||
|
|
@ -278,7 +299,7 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
// Check user's realm limit (e.g., 5 realms per user)
|
// Check user's realm limit (e.g., 5 realms per user)
|
||||||
*dbClient << "SELECT COUNT(*) as count FROM realms WHERE user_id = $1"
|
*dbClient << "SELECT COUNT(*) as count FROM realms WHERE user_id = $1"
|
||||||
<< user.id
|
<< user.id
|
||||||
>> [dbClient, user, name, realmType, callback](const Result& r3) {
|
>> [dbClient, user, name, displayName, realmType, callback](const Result& r3) {
|
||||||
if (!r3.empty() && r3[0]["count"].as<int64_t>() >= 5) {
|
if (!r3.empty() && r3[0]["count"].as<int64_t>() >= 5) {
|
||||||
callback(jsonError("You have reached the maximum number of realms (5)"));
|
callback(jsonError("You have reached the maximum number of realms (5)"));
|
||||||
return;
|
return;
|
||||||
|
|
@ -288,10 +309,10 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
// Stream realm - generate stream key
|
// Stream realm - generate stream key
|
||||||
std::string streamKey = generateStreamKey();
|
std::string streamKey = generateStreamKey();
|
||||||
|
|
||||||
*dbClient << "INSERT INTO realms (user_id, name, stream_key, realm_type) "
|
*dbClient << "INSERT INTO realms (user_id, name, display_name, stream_key, realm_type) "
|
||||||
"VALUES ($1, $2, $3, 'stream') RETURNING id"
|
"VALUES ($1, $2, $3, $4, 'stream') RETURNING id"
|
||||||
<< user.id << name << streamKey
|
<< user.id << name << displayName << streamKey
|
||||||
>> [callback, name, streamKey](const Result& r4) {
|
>> [callback, name, displayName, streamKey](const Result& r4) {
|
||||||
if (r4.empty()) {
|
if (r4.empty()) {
|
||||||
callback(jsonError("Failed to create realm"));
|
callback(jsonError("Failed to create realm"));
|
||||||
return;
|
return;
|
||||||
|
|
@ -304,6 +325,7 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
resp["realm"]["id"] = static_cast<Json::Int64>(r4[0]["id"].as<int64_t>());
|
resp["realm"]["id"] = static_cast<Json::Int64>(r4[0]["id"].as<int64_t>());
|
||||||
resp["realm"]["name"] = name;
|
resp["realm"]["name"] = name;
|
||||||
|
resp["realm"]["displayName"] = displayName;
|
||||||
resp["realm"]["streamKey"] = streamKey;
|
resp["realm"]["streamKey"] = streamKey;
|
||||||
resp["realm"]["type"] = "stream";
|
resp["realm"]["type"] = "stream";
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
|
|
@ -311,10 +333,10 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
>> DB_ERROR_MSG(callback, "create realm", "Failed to create realm");
|
>> DB_ERROR_MSG(callback, "create realm", "Failed to create realm");
|
||||||
} else if (realmType == "watch") {
|
} else if (realmType == "watch") {
|
||||||
// Watch room - no stream key, initialize watch_room_state
|
// Watch room - no stream key, initialize watch_room_state
|
||||||
*dbClient << "INSERT INTO realms (user_id, name, realm_type) "
|
*dbClient << "INSERT INTO realms (user_id, name, display_name, realm_type) "
|
||||||
"VALUES ($1, $2, 'watch') RETURNING id"
|
"VALUES ($1, $2, $3, 'watch') RETURNING id"
|
||||||
<< user.id << name
|
<< user.id << name << displayName
|
||||||
>> [callback, dbClient, name](const Result& r4) {
|
>> [callback, dbClient, name, displayName](const Result& r4) {
|
||||||
if (r4.empty()) {
|
if (r4.empty()) {
|
||||||
callback(jsonError("Failed to create realm"));
|
callback(jsonError("Failed to create realm"));
|
||||||
return;
|
return;
|
||||||
|
|
@ -325,11 +347,12 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
// Initialize watch_room_state
|
// Initialize watch_room_state
|
||||||
*dbClient << "INSERT INTO watch_room_state (realm_id) VALUES ($1)"
|
*dbClient << "INSERT INTO watch_room_state (realm_id) VALUES ($1)"
|
||||||
<< realmId
|
<< realmId
|
||||||
>> [callback, name, realmId](const Result&) {
|
>> [callback, name, displayName, realmId](const Result&) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
resp["realm"]["id"] = static_cast<Json::Int64>(realmId);
|
resp["realm"]["id"] = static_cast<Json::Int64>(realmId);
|
||||||
resp["realm"]["name"] = name;
|
resp["realm"]["name"] = name;
|
||||||
|
resp["realm"]["displayName"] = displayName;
|
||||||
resp["realm"]["type"] = "watch";
|
resp["realm"]["type"] = "watch";
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -338,10 +361,10 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
>> DB_ERROR_MSG(callback, "create realm", "Failed to create realm");
|
>> DB_ERROR_MSG(callback, "create realm", "Failed to create realm");
|
||||||
} else {
|
} else {
|
||||||
// Video, Audio, or Ebook realm - no stream key needed
|
// Video, Audio, or Ebook realm - no stream key needed
|
||||||
*dbClient << "INSERT INTO realms (user_id, name, realm_type) "
|
*dbClient << "INSERT INTO realms (user_id, name, display_name, realm_type) "
|
||||||
"VALUES ($1, $2, $3) RETURNING id"
|
"VALUES ($1, $2, $3, $4) RETURNING id"
|
||||||
<< user.id << name << realmType
|
<< user.id << name << displayName << realmType
|
||||||
>> [callback, name, realmType](const Result& r4) {
|
>> [callback, name, displayName, realmType](const Result& r4) {
|
||||||
if (r4.empty()) {
|
if (r4.empty()) {
|
||||||
callback(jsonError("Failed to create realm"));
|
callback(jsonError("Failed to create realm"));
|
||||||
return;
|
return;
|
||||||
|
|
@ -351,6 +374,7 @@ void RealmController::createRealm(const HttpRequestPtr &req,
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
resp["realm"]["id"] = static_cast<Json::Int64>(r4[0]["id"].as<int64_t>());
|
resp["realm"]["id"] = static_cast<Json::Int64>(r4[0]["id"].as<int64_t>());
|
||||||
resp["realm"]["name"] = name;
|
resp["realm"]["name"] = name;
|
||||||
|
resp["realm"]["displayName"] = displayName;
|
||||||
resp["realm"]["type"] = realmType;
|
resp["realm"]["type"] = realmType;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
|
|
@ -638,12 +662,7 @@ void RealmController::updateRealm(const HttpRequestPtr &req,
|
||||||
>> DB_ERROR(callback, "check realm name");
|
>> DB_ERROR(callback, "check realm name");
|
||||||
} else if (json->isMember("description")) {
|
} else if (json->isMember("description")) {
|
||||||
// Update realm description
|
// Update realm description
|
||||||
std::string description = (*json)["description"].asString();
|
std::string description = sanitizeUserInput((*json)["description"].asString(), 500);
|
||||||
|
|
||||||
// Limit description length
|
|
||||||
if (description.length() > 500) {
|
|
||||||
description = description.substr(0, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
*dbClient << "UPDATE realms SET description = $1 WHERE id = $2 AND user_id = $3"
|
*dbClient << "UPDATE realms SET description = $1 WHERE id = $2 AND user_id = $3"
|
||||||
<< description << id << user.id
|
<< description << id << user.id
|
||||||
|
|
@ -759,7 +778,7 @@ void RealmController::getRealmByName(const HttpRequestPtr &,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
const std::string &realmName) {
|
const std::string &realmName) {
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT r.id, r.name, r.realm_type, r.is_live, r.viewer_count, r.viewer_multiplier, "
|
*dbClient << "SELECT r.id, r.name, r.display_name, r.realm_type, r.is_live, r.viewer_count, r.viewer_multiplier, "
|
||||||
"r.chat_enabled, r.chat_guests_allowed, "
|
"r.chat_enabled, r.chat_guests_allowed, "
|
||||||
"r.chat_slow_mode_seconds, r.chat_retention_hours, "
|
"r.chat_slow_mode_seconds, r.chat_retention_hours, "
|
||||||
"r.offline_image_url, r.description, r.user_id, r.playlist_control_mode, r.playlist_whitelist, r.title_color, "
|
"r.offline_image_url, r.description, r.user_id, r.playlist_control_mode, r.playlist_whitelist, r.title_color, "
|
||||||
|
|
@ -779,18 +798,13 @@ void RealmController::getRealmByName(const HttpRequestPtr &,
|
||||||
auto& realm = resp["realm"];
|
auto& realm = resp["realm"];
|
||||||
realm["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
realm["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
||||||
realm["name"] = r[0]["name"].as<std::string>();
|
realm["name"] = r[0]["name"].as<std::string>();
|
||||||
|
realm["displayName"] = r[0]["display_name"].isNull() ? r[0]["name"].as<std::string>() : r[0]["display_name"].as<std::string>();
|
||||||
realm["type"] = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
|
realm["type"] = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
|
||||||
realm["isLive"] = r[0]["is_live"].as<bool>();
|
realm["isLive"] = r[0]["is_live"].as<bool>();
|
||||||
// Apply viewer multiplier for visual "viewbotting" effect
|
// Apply viewer multiplier for visual "viewbotting" effect
|
||||||
int64_t viewerCount = r[0]["viewer_count"].as<int64_t>();
|
int64_t viewerCount = r[0]["viewer_count"].as<int64_t>();
|
||||||
int multiplier = r[0]["viewer_multiplier"].isNull() ? 1 : r[0]["viewer_multiplier"].as<int>();
|
int multiplier = r[0]["viewer_multiplier"].isNull() ? 1 : r[0]["viewer_multiplier"].as<int>();
|
||||||
int64_t displayCount = viewerCount * multiplier;
|
int64_t displayCount = viewerCount * multiplier;
|
||||||
// Add random 0-99 bonus when multiplier is active
|
|
||||||
if (multiplier > 1) {
|
|
||||||
static thread_local std::mt19937 rng(std::random_device{}());
|
|
||||||
std::uniform_int_distribution<int> dist(0, 99);
|
|
||||||
displayCount += dist(rng);
|
|
||||||
}
|
|
||||||
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
||||||
realm["chatEnabled"] = r[0]["chat_enabled"].as<bool>();
|
realm["chatEnabled"] = r[0]["chat_enabled"].as<bool>();
|
||||||
realm["chatGuestsAllowed"] = r[0]["chat_guests_allowed"].as<bool>();
|
realm["chatGuestsAllowed"] = r[0]["chat_guests_allowed"].as<bool>();
|
||||||
|
|
@ -835,12 +849,6 @@ void RealmController::getLiveRealms(const HttpRequestPtr &,
|
||||||
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
||||||
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
||||||
int64_t displayCount = viewerCount * multiplier;
|
int64_t displayCount = viewerCount * multiplier;
|
||||||
// Add random 0-99 bonus when multiplier is active
|
|
||||||
if (multiplier > 1) {
|
|
||||||
static thread_local std::mt19937 rng(std::random_device{}());
|
|
||||||
std::uniform_int_distribution<int> dist(0, 99);
|
|
||||||
displayCount += dist(rng);
|
|
||||||
}
|
|
||||||
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
||||||
realm["username"] = row["username"].as<std::string>();
|
realm["username"] = row["username"].as<std::string>();
|
||||||
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||||
|
|
@ -881,12 +889,6 @@ void RealmController::getAllRealms(const HttpRequestPtr &,
|
||||||
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
||||||
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
||||||
int64_t displayCount = viewerCount * multiplier;
|
int64_t displayCount = viewerCount * multiplier;
|
||||||
// Add random 0-99 bonus when multiplier is active
|
|
||||||
if (multiplier > 1) {
|
|
||||||
static thread_local std::mt19937 rng(std::random_device{}());
|
|
||||||
std::uniform_int_distribution<int> dist(0, 99);
|
|
||||||
displayCount += dist(rng);
|
|
||||||
}
|
|
||||||
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
||||||
realm["chatEnabled"] = row["chat_enabled"].as<bool>();
|
realm["chatEnabled"] = row["chat_enabled"].as<bool>();
|
||||||
realm["chatGuestsAllowed"] = row["chat_guests_allowed"].as<bool>();
|
realm["chatGuestsAllowed"] = row["chat_guests_allowed"].as<bool>();
|
||||||
|
|
@ -954,18 +956,10 @@ void RealmController::getRealmStats(const HttpRequestPtr &,
|
||||||
Json::Value json;
|
Json::Value json;
|
||||||
json["success"] = true;
|
json["success"] = true;
|
||||||
|
|
||||||
// Calculate random bonus when multiplier is active
|
|
||||||
int randomBonus = 0;
|
|
||||||
if (multiplier > 1) {
|
|
||||||
static thread_local std::mt19937 rng(std::random_device{}());
|
|
||||||
std::uniform_int_distribution<int> dist(0, 99);
|
|
||||||
randomBonus = dist(rng);
|
|
||||||
}
|
|
||||||
|
|
||||||
auto& s = json["stats"];
|
auto& s = json["stats"];
|
||||||
// Apply viewer multiplier for visual "viewbotting" effect
|
// Apply viewer multiplier for visual "viewbotting" effect
|
||||||
s["connections"] = static_cast<Json::Int64>(stats.uniqueViewers * multiplier + randomBonus);
|
s["connections"] = static_cast<Json::Int64>(stats.uniqueViewers * multiplier);
|
||||||
s["total_connections"] = static_cast<Json::Int64>(stats.totalConnections * multiplier + randomBonus);
|
s["total_connections"] = static_cast<Json::Int64>(stats.totalConnections * multiplier);
|
||||||
s["bytes_in"] = static_cast<Json::Int64>(stats.totalBytesIn);
|
s["bytes_in"] = static_cast<Json::Int64>(stats.totalBytesIn);
|
||||||
s["bytes_out"] = static_cast<Json::Int64>(stats.totalBytesOut);
|
s["bytes_out"] = static_cast<Json::Int64>(stats.totalBytesOut);
|
||||||
s["bitrate"] = stats.bitrate;
|
s["bitrate"] = stats.bitrate;
|
||||||
|
|
@ -1017,12 +1011,6 @@ void RealmController::getPublicUserRealms(const HttpRequestPtr &,
|
||||||
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
||||||
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
||||||
int64_t displayCount = viewerCount * multiplier;
|
int64_t displayCount = viewerCount * multiplier;
|
||||||
// Add random 0-99 bonus when multiplier is active
|
|
||||||
if (multiplier > 1) {
|
|
||||||
static thread_local std::mt19937 rng(std::random_device{}());
|
|
||||||
std::uniform_int_distribution<int> dist(0, 99);
|
|
||||||
displayCount += dist(rng);
|
|
||||||
}
|
|
||||||
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
||||||
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
|
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
|
||||||
realm["videoCount"] = static_cast<Json::Int64>(row["video_count"].as<int64_t>());
|
realm["videoCount"] = static_cast<Json::Int64>(row["video_count"].as<int64_t>());
|
||||||
|
|
|
||||||
|
|
@ -601,19 +601,13 @@ void VideoController::uploadVideo(const HttpRequestPtr &req,
|
||||||
const auto& file = parser.getFiles()[0];
|
const auto& file = parser.getFiles()[0];
|
||||||
|
|
||||||
// Get title from form data
|
// Get title from form data
|
||||||
std::string title = parser.getParameter<std::string>("title");
|
std::string title = sanitizeUserInput(parser.getParameter<std::string>("title"), 255);
|
||||||
if (title.empty()) {
|
if (title.empty()) {
|
||||||
title = "Untitled Video";
|
title = "Untitled Video";
|
||||||
}
|
}
|
||||||
if (title.length() > 255) {
|
|
||||||
title = title.substr(0, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get optional description
|
// Get optional description
|
||||||
std::string description = parser.getParameter<std::string>("description");
|
std::string description = sanitizeUserInput(parser.getParameter<std::string>("description"), 5000);
|
||||||
if (description.length() > 5000) {
|
|
||||||
description = description.substr(0, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (500MB max)
|
// Validate file size (500MB max)
|
||||||
const size_t maxSize = 500 * 1024 * 1024;
|
const size_t maxSize = 500 * 1024 * 1024;
|
||||||
|
|
@ -794,12 +788,10 @@ void VideoController::updateVideo(const HttpRequestPtr &req,
|
||||||
std::string title, description;
|
std::string title, description;
|
||||||
|
|
||||||
if (json->isMember("title")) {
|
if (json->isMember("title")) {
|
||||||
title = (*json)["title"].asString();
|
title = sanitizeUserInput((*json)["title"].asString(), 255);
|
||||||
if (title.length() > 255) title = title.substr(0, 255);
|
|
||||||
}
|
}
|
||||||
if (json->isMember("description")) {
|
if (json->isMember("description")) {
|
||||||
description = (*json)["description"].asString();
|
description = sanitizeUserInput((*json)["description"].asString(), 5000);
|
||||||
if (description.length() > 5000) description = description.substr(0, 5000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json->isMember("title") && json->isMember("description")) {
|
if (json->isMember("title") && json->isMember("description")) {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,20 @@ BEGIN
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
-- Add display_name column if it doesn't exist (for existing databases)
|
||||||
|
-- display_name stores the user's preferred casing, name stores lowercase URL slug
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'realms' AND column_name = 'display_name'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE realms ADD COLUMN display_name VARCHAR(255);
|
||||||
|
-- Populate display_name from name for existing realms
|
||||||
|
UPDATE realms SET display_name = name WHERE display_name IS NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- Make stream_key nullable for video realms (for existing databases)
|
-- Make stream_key nullable for video realms (for existing databases)
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ function shouldSpeak(message) {
|
||||||
if (filter === 'guests' && !message.isGuest) return false;
|
if (filter === 'guests' && !message.isGuest) return false;
|
||||||
if (filter === 'registered' && message.isGuest) return false;
|
if (filter === 'registered' && message.isGuest) return false;
|
||||||
|
|
||||||
|
// Skip graffiti messages (they're just images, nothing to read)
|
||||||
|
if (message.content && /^\[graffiti\].*\[\/graffiti\]$/i.test(message.content.trim())) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,8 +93,11 @@ function cleanTextForTTS(text) {
|
||||||
clean = clean.replace(/~~(.+?)~~/g, '$1'); // strikethrough
|
clean = clean.replace(/~~(.+?)~~/g, '$1'); // strikethrough
|
||||||
clean = clean.replace(/`(.+?)`/g, '$1'); // code
|
clean = clean.replace(/`(.+?)`/g, '$1'); // code
|
||||||
|
|
||||||
// Remove URLs
|
// Remove URLs completely (don't read them aloud)
|
||||||
clean = clean.replace(/https?:\/\/\S+/g, 'link');
|
clean = clean.replace(/https?:\/\/\S+/g, '');
|
||||||
|
|
||||||
|
// Remove any graffiti tags that might be embedded
|
||||||
|
clean = clean.replace(/\[graffiti\].*?\[\/graffiti\]/gi, '');
|
||||||
|
|
||||||
// Limit length to prevent very long TTS
|
// Limit length to prevent very long TTS
|
||||||
if (clean.length > 200) {
|
if (clean.length > 200) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { gamesOverlay, hasGame, currentGame, gameMode } from '$lib/stores/gamesOverlay';
|
import { gamesOverlay, hasGame, currentGame, gameMode } from '$lib/stores/gamesOverlay';
|
||||||
import { nakama, ChessOpCode } from '$lib/stores/nakama';
|
import { nakama, ChessOpCode } from '$lib/stores/nakama';
|
||||||
import { auth } from '$lib/stores/auth';
|
import { auth } from '$lib/stores/auth';
|
||||||
|
|
@ -390,6 +392,11 @@
|
||||||
myColor = null;
|
myColor = null;
|
||||||
|
|
||||||
gamesOverlay.closeGame();
|
gamesOverlay.closeGame();
|
||||||
|
|
||||||
|
// Clear match param from URL if on chess page
|
||||||
|
if ($page.url.pathname === '/games/chess' && $page.url.searchParams.has('match')) {
|
||||||
|
goto('/games/chess', { replaceState: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMinimize() {
|
function handleMinimize() {
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,8 @@
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: #1a1a1a;
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
|
|
@ -140,7 +140,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-bottom: 1px solid var(--border, #333);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(255, 215, 0, 0.05);
|
background: rgba(255, 215, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #666;
|
color: var(--text-faint);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|
@ -180,7 +180,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,11 +197,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipient-info span {
|
.recipient-info span {
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipient-info strong {
|
.recipient-info strong {
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-row {
|
.balance-row {
|
||||||
|
|
@ -213,11 +213,11 @@
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #ccc;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-row strong {
|
.balance-row strong {
|
||||||
color: #ffd700;
|
color: var(--accent-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.coin-icon {
|
.coin-icon {
|
||||||
|
|
@ -240,24 +240,24 @@
|
||||||
.input-group label {
|
.input-group label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input {
|
.input-group input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
background: #222;
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s ease;
|
transition: border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input:focus {
|
.input-group input:focus {
|
||||||
border-color: #ffd700;
|
border-color: var(--accent-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input:disabled {
|
.input-group input:disabled {
|
||||||
|
|
@ -303,12 +303,12 @@
|
||||||
.burn-details p {
|
.burn-details p {
|
||||||
margin: 0 0 2px 0;
|
margin: 0 0 2px 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #ccc;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.burn-details .detail-small {
|
.burn-details .detail-small {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-loading {
|
.preview-loading {
|
||||||
|
|
@ -320,7 +320,7 @@
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
|
|
@ -348,7 +348,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-top: 1px solid var(--border, #333);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-footer button {
|
.panel-footer button {
|
||||||
|
|
@ -363,8 +363,8 @@
|
||||||
|
|
||||||
.cancel-btn {
|
.cancel-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn:hover:not(:disabled) {
|
.cancel-btn:hover:not(:disabled) {
|
||||||
|
|
@ -390,7 +390,7 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<div class="tip-panel" bind:this={panelElement} role="dialog" aria-modal="true">
|
<div class="tip-panel" bind:this={panelElement} role="dialog" aria-modal="true" on:click|stopPropagation>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3>
|
<h3>
|
||||||
<span class="header-icon">Ü</span>
|
<span class="header-icon">Ü</span>
|
||||||
|
|
|
||||||
|
|
@ -407,7 +407,7 @@
|
||||||
.chat-input {
|
.chat-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0;
|
padding: 2px 2px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
flex-shrink: 0; /* Prevent input from shrinking */
|
flex-shrink: 0; /* Prevent input from shrinking */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -745,7 +745,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
background: #0d0d0d;
|
background: #000;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopResize() {
|
function stopResize() {
|
||||||
|
// Save height to localStorage after resize ends
|
||||||
|
if (isResizing && isDocked) {
|
||||||
|
localStorage.setItem('terminalHeight', String(terminalHeight));
|
||||||
|
}
|
||||||
isResizing = false;
|
isResizing = false;
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +115,9 @@
|
||||||
function toggleDock() {
|
function toggleDock() {
|
||||||
isDocked = !isDocked;
|
isDocked = !isDocked;
|
||||||
if (isDocked) {
|
if (isDocked) {
|
||||||
terminalHeight = 500;
|
// Restore saved height or use default
|
||||||
|
const savedHeight = localStorage.getItem('terminalHeight');
|
||||||
|
terminalHeight = savedHeight ? parseInt(savedHeight, 10) || 333 : 333;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +266,14 @@
|
||||||
terminalHotkey = savedHotkey;
|
terminalHotkey = savedHotkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedHeight = localStorage.getItem('terminalHeight');
|
||||||
|
if (savedHeight) {
|
||||||
|
const height = parseInt(savedHeight, 10);
|
||||||
|
if (!isNaN(height) && height >= 200) {
|
||||||
|
terminalHeight = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update time every second
|
// Update time every second
|
||||||
timeInterval = setInterval(() => {
|
timeInterval = setInterval(() => {
|
||||||
currentTime = new Date();
|
currentTime = new Date();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
|
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { filteredMessages, connectionStatus, chatUserInfo, fetchRealmStats, availableRealms } from '$lib/chat/chatStore';
|
import { filteredMessages, connectionStatus, chatUserInfo, fetchRealmStats, availableRealms, currentRealmId } from '$lib/chat/chatStore';
|
||||||
import { chatWebSocket } from '$lib/chat/chatWebSocket';
|
import { chatWebSocket } from '$lib/chat/chatWebSocket';
|
||||||
import { auth, userColor } from '$lib/stores/auth';
|
import { auth, userColor } from '$lib/stores/auth';
|
||||||
import ChatMessage from '$lib/components/chat/ChatMessage.svelte';
|
import ChatMessage from '$lib/components/chat/ChatMessage.svelte';
|
||||||
|
|
@ -36,6 +36,32 @@
|
||||||
$: isConnected = $connectionStatus === 'connected';
|
$: isConnected = $connectionStatus === 'connected';
|
||||||
$: username = $auth.user?.username || $chatUserInfo.username || 'guest';
|
$: username = $auth.user?.username || $chatUserInfo.username || 'guest';
|
||||||
|
|
||||||
|
// Sync local realmId with store's currentRealmId when it changes (e.g., from WebSocket connection)
|
||||||
|
$: if ($currentRealmId && $currentRealmId !== realmId) {
|
||||||
|
realmId = $currentRealmId;
|
||||||
|
dispatch('realmChange', { realmId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a merged and sorted array of all messages (system + chat) for chronological display
|
||||||
|
$: allMessages = (() => {
|
||||||
|
// Convert system messages to unified format with numeric timestamps
|
||||||
|
const sysWithTime = systemMessages.map(s => ({
|
||||||
|
...s,
|
||||||
|
type: 'system',
|
||||||
|
sortTime: s.id // id is Date.now() + Math.random(), so it works for sorting
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Convert chat messages to unified format
|
||||||
|
const chatWithTime = $filteredMessages.map(m => ({
|
||||||
|
...m,
|
||||||
|
type: 'chat',
|
||||||
|
sortTime: typeof m.timestamp === 'number' ? m.timestamp : new Date(m.timestamp).getTime()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Merge and sort by time
|
||||||
|
return [...sysWithTime, ...chatWithTime].sort((a, b) => a.sortTime - b.sortTime);
|
||||||
|
})();
|
||||||
|
|
||||||
// Exposed methods
|
// Exposed methods
|
||||||
export function addSystemMessage(text) {
|
export function addSystemMessage(text) {
|
||||||
systemMessages = [...systemMessages, {
|
systemMessages = [...systemMessages, {
|
||||||
|
|
@ -157,8 +183,7 @@
|
||||||
|
|
||||||
// Auto-scroll when messages change
|
// Auto-scroll when messages change
|
||||||
$: if (browser) {
|
$: if (browser) {
|
||||||
systemMessages.length;
|
allMessages.length;
|
||||||
$filteredMessages.length;
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
@ -223,30 +248,30 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="terminal-messages" on:scroll={handleScroll}>
|
<div class="terminal-messages" on:scroll={handleScroll}>
|
||||||
{#each systemMessages as sysMsg (sysMsg.id)}
|
{#each allMessages as msg (msg.type === 'system' ? msg.id : msg.messageId)}
|
||||||
|
{#if msg.type === 'system'}
|
||||||
<div class="system-message">
|
<div class="system-message">
|
||||||
<span class="system-prefix">[{sysMsg.timestamp}]</span>
|
<span class="system-prefix">[{msg.timestamp}]</span>
|
||||||
<span class="system-text">{sysMsg.text}</span>
|
<span class="system-text">{msg.text}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{:else}
|
||||||
|
|
||||||
{#each $filteredMessages as message (message.messageId)}
|
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
{message}
|
message={msg}
|
||||||
showHeader={true}
|
showHeader={true}
|
||||||
currentUserId={$chatUserInfo.userId}
|
currentUserId={$chatUserInfo.userId}
|
||||||
currentRealmId={realmId}
|
currentRealmId={realmId}
|
||||||
isModerator={$chatUserInfo.isModerator}
|
isModerator={$chatUserInfo.isModerator}
|
||||||
terminalMode={true}
|
terminalMode={true}
|
||||||
{renderStickers}
|
{renderStickers}
|
||||||
on:delete={() => chatWebSocket.deleteMessage(message.messageId)}
|
on:delete={() => chatWebSocket.deleteMessage(msg.messageId)}
|
||||||
on:showProfile={handleShowProfile}
|
on:showProfile={handleShowProfile}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Inline command input at end of messages -->
|
<!-- Inline command input at end of messages -->
|
||||||
<form class="terminal-input-line" on:submit={handleCommand}>
|
<form class="terminal-input-line" on:submit={handleCommand}>
|
||||||
<span class="prompt">{username}@realms:~$</span>
|
<span class="prompt" class:disconnected={!isConnected}>{username}@{realmId || 'global'}:~$</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="terminal-input"
|
class="terminal-input"
|
||||||
|
|
@ -283,6 +308,10 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt.disconnected {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-input {
|
.terminal-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import {
|
||||||
leaveRealmFilter,
|
leaveRealmFilter,
|
||||||
resetToGlobal,
|
resetToGlobal,
|
||||||
fetchRealmStats,
|
fetchRealmStats,
|
||||||
chatUserInfo
|
chatUserInfo,
|
||||||
|
currentRealmId
|
||||||
} from '$lib/chat/chatStore';
|
} from '$lib/chat/chatStore';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
|
@ -307,6 +308,7 @@ async function listRealms(addSystemMessage) {
|
||||||
await fetchRealmStats();
|
await fetchRealmStats();
|
||||||
const realms = get(availableRealms);
|
const realms = get(availableRealms);
|
||||||
const selected = get(selectedRealms);
|
const selected = get(selectedRealms);
|
||||||
|
const connected = get(currentRealmId);
|
||||||
const isGlobal = selected.size === 0;
|
const isGlobal = selected.size === 0;
|
||||||
|
|
||||||
addSystemMessage('=== Available Realms ===');
|
addSystemMessage('=== Available Realms ===');
|
||||||
|
|
@ -314,17 +316,20 @@ async function listRealms(addSystemMessage) {
|
||||||
addSystemMessage('No active realms');
|
addSystemMessage('No active realms');
|
||||||
} else {
|
} else {
|
||||||
realms.forEach((realm) => {
|
realms.forEach((realm) => {
|
||||||
|
const isConnected = String(realm.realmId) === String(connected);
|
||||||
const checked = isGlobal || selected.has(realm.realmId) ? '[✓]' : '[ ]';
|
const checked = isGlobal || selected.has(realm.realmId) ? '[✓]' : '[ ]';
|
||||||
|
const connMarker = isConnected ? ' <--' : '';
|
||||||
const users = realm.participantCount || 0;
|
const users = realm.participantCount || 0;
|
||||||
addSystemMessage(`${checked} ${realm.realmId} (${users} users)`);
|
addSystemMessage(`${checked} ${realm.realmId} (${users} users)${connMarker}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addSystemMessage('');
|
addSystemMessage('');
|
||||||
|
addSystemMessage(`Connected to: ${connected || 'none'} (messages sent here)`);
|
||||||
if (isGlobal) {
|
if (isGlobal) {
|
||||||
addSystemMessage('Currently: Global (all realms)');
|
addSystemMessage('Viewing: Global (all realms)');
|
||||||
} else {
|
} else {
|
||||||
const names = Array.from(selected).join(', ');
|
const names = Array.from(selected).join(', ');
|
||||||
addSystemMessage(`Filtered to: ${names}`);
|
addSystemMessage(`Viewing: ${names}`);
|
||||||
}
|
}
|
||||||
addSystemMessage('========================');
|
addSystemMessage('========================');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -350,7 +355,7 @@ async function joinRealmChat(nameOrId, addSystemMessage, chatWebSocket, setRealm
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
targetRealmId = String(data.realm.id);
|
targetRealmId = String(data.realm.id);
|
||||||
realmName = data.realm.name || nameOrId;
|
realmName = data.realm.displayName || data.realm.name || nameOrId;
|
||||||
} else {
|
} else {
|
||||||
addSystemMessage(`Realm "${nameOrId}" not found`);
|
addSystemMessage(`Realm "${nameOrId}" not found`);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,7 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.playlist-container {
|
.playlist-container {
|
||||||
background: #1a1a1a;
|
background: #000;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,16 @@
|
||||||
let currentPlaylistItemId = null; // Track playlist item ID to detect changes even with same video
|
let currentPlaylistItemId = null; // Track playlist item ID to detect changes even with same video
|
||||||
let durationReportedForItemId = null; // Track which playlist item we've reported duration for
|
let durationReportedForItemId = null; // Track which playlist item we've reported duration for
|
||||||
let lastControllerSeekTime = 0; // Debounce controller seek updates
|
let lastControllerSeekTime = 0; // Debounce controller seek updates
|
||||||
|
let lastSeekTime = 0; // Track last seek time for rate-limiting
|
||||||
|
let leadInEndedAt = 0; // Track when lead-in ended for grace period
|
||||||
|
let wasInLeadIn = false; // Track previous lead-in state
|
||||||
|
|
||||||
const SYNC_CHECK_INTERVAL = 1000; // Check sync every second (matches server sync interval)
|
const SYNC_CHECK_INTERVAL = 1000; // Check sync every second (matches server sync interval)
|
||||||
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync)
|
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync)
|
||||||
const MIN_SYNC_INTERVAL = 1000; // Minimum 1 second between sync checks (server pushes every 1s)
|
const MIN_SYNC_INTERVAL = 1000; // Minimum 1 second between sync checks (server pushes every 1s)
|
||||||
const CONTROLLER_SEEK_DEBOUNCE = 2000; // Debounce controller seeks by 2 seconds
|
const CONTROLLER_SEEK_DEBOUNCE = 2000; // Debounce controller seeks by 2 seconds
|
||||||
|
const SEEK_RATE_LIMIT = 2000; // Minimum 2 seconds between seeks
|
||||||
|
const POST_LEAD_IN_GRACE = 500; // 500ms grace period after lead-in ends
|
||||||
|
|
||||||
// Load YouTube IFrame API
|
// Load YouTube IFrame API
|
||||||
function loadYouTubeAPI() {
|
function loadYouTubeAPI() {
|
||||||
|
|
@ -171,16 +176,28 @@
|
||||||
// During lead-in period, let video buffer without seeking
|
// During lead-in period, let video buffer without seeking
|
||||||
// Server sends leadIn=true for 3 seconds after play starts
|
// Server sends leadIn=true for 3 seconds after play starts
|
||||||
if (storeState.leadIn) {
|
if (storeState.leadIn) {
|
||||||
|
wasInLeadIn = true;
|
||||||
// During lead-in, just ensure video is loading/buffering
|
// During lead-in, just ensure video is loading/buffering
|
||||||
// Don't seek or sync position - wait for lead-in to complete
|
// Don't seek or sync position - wait for lead-in to complete
|
||||||
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
|
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
|
||||||
ignoreStateChange = true;
|
ignoreStateChange = true;
|
||||||
player.playVideo();
|
player.playVideo();
|
||||||
setTimeout(() => { ignoreStateChange = false; }, 500);
|
setTimeout(() => { ignoreStateChange = false; }, 1500);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track when lead-in ends for grace period
|
||||||
|
if (wasInLeadIn && !storeState.leadIn) {
|
||||||
|
wasInLeadIn = false;
|
||||||
|
leadInEndedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply grace period after lead-in ends - don't seek immediately
|
||||||
|
if (leadInEndedAt > 0 && (now - leadInEndedAt) < POST_LEAD_IN_GRACE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const expectedTime = watchSync.getExpectedTime();
|
const expectedTime = watchSync.getExpectedTime();
|
||||||
const currentTime = player.getCurrentTime();
|
const currentTime = player.getCurrentTime();
|
||||||
const drift = Math.abs(currentTime - expectedTime);
|
const drift = Math.abs(currentTime - expectedTime);
|
||||||
|
|
@ -205,10 +222,15 @@
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-controller - sync back to server time
|
// Non-controller - sync back to server time
|
||||||
|
// Rate-limit seeks to prevent stuttering from consecutive seeks
|
||||||
|
if (now - lastSeekTime < SEEK_RATE_LIMIT) {
|
||||||
|
return; // Skip this seek, wait for rate limit
|
||||||
|
}
|
||||||
console.log(`Sync drift detected: ${drift.toFixed(2)}s, seeking to ${expectedTime.toFixed(2)}s`);
|
console.log(`Sync drift detected: ${drift.toFixed(2)}s, seeking to ${expectedTime.toFixed(2)}s`);
|
||||||
ignoreStateChange = true;
|
ignoreStateChange = true;
|
||||||
player.seekTo(expectedTime, true);
|
player.seekTo(expectedTime, true);
|
||||||
setTimeout(() => { ignoreStateChange = false; }, 500);
|
lastSeekTime = now;
|
||||||
|
setTimeout(() => { ignoreStateChange = false; }, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,11 +239,11 @@
|
||||||
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
|
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
|
||||||
ignoreStateChange = true;
|
ignoreStateChange = true;
|
||||||
player.playVideo();
|
player.playVideo();
|
||||||
setTimeout(() => { ignoreStateChange = false; }, 500);
|
setTimeout(() => { ignoreStateChange = false; }, 1500);
|
||||||
} else if (!shouldBePlaying && isPlayerPlaying) {
|
} else if (!shouldBePlaying && isPlayerPlaying) {
|
||||||
ignoreStateChange = true;
|
ignoreStateChange = true;
|
||||||
player.pauseVideo();
|
player.pauseVideo();
|
||||||
setTimeout(() => { ignoreStateChange = false; }, 500);
|
setTimeout(() => { ignoreStateChange = false; }, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,11 +267,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// React to state changes from the store
|
// Note: Sync is handled by the interval at onPlayerReady (line 99)
|
||||||
$: if (playerReady && player && $watchSync.serverTime > lastSyncTime) {
|
// We don't trigger sync on every serverTime update to avoid double-syncing
|
||||||
lastSyncTime = $watchSync.serverTime;
|
// which causes stuttering. The interval runs every 1 second which is sufficient.
|
||||||
checkAndSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle window event for state changes from other users
|
// Handle window event for state changes from other users
|
||||||
function handleStateChange(event) {
|
function handleStateChange(event) {
|
||||||
|
|
@ -270,6 +290,11 @@
|
||||||
} else if (action === 'skip' || action === 'video_changed') {
|
} else if (action === 'skip' || action === 'video_changed') {
|
||||||
// Video change will be handled by the reactive statement above
|
// Video change will be handled by the reactive statement above
|
||||||
setTimeout(() => checkAndSync(true), 1000);
|
setTimeout(() => checkAndSync(true), 1000);
|
||||||
|
} else if (action === 'locked_restart' || action === 'repeat') {
|
||||||
|
// Locked video loop - seek to beginning and play
|
||||||
|
player.seekTo(0, true);
|
||||||
|
player.playVideo();
|
||||||
|
setTimeout(() => checkAndSync(true), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => { ignoreStateChange = false; }, 1000);
|
setTimeout(() => { ignoreStateChange = false; }, 1000);
|
||||||
|
|
@ -280,7 +305,7 @@
|
||||||
if (playerReady && player) {
|
if (playerReady && player) {
|
||||||
ignoreStateChange = true;
|
ignoreStateChange = true;
|
||||||
player.seekTo(time, true);
|
player.seekTo(time, true);
|
||||||
setTimeout(() => { ignoreStateChange = false; }, 500);
|
setTimeout(() => { ignoreStateChange = false; }, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -484,12 +484,69 @@ function createAudioPlaylistStore() {
|
||||||
...state,
|
...state,
|
||||||
nowPlaying: null
|
nowPlaying: null
|
||||||
}));
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sync state from localStorage (called when storage event fires from another tab)
|
||||||
|
// Only syncs queue/settings, not playback state to avoid one tab controlling another
|
||||||
|
syncFromStorage(syncedState) {
|
||||||
|
update(state => {
|
||||||
|
// Determine if we need to adjust currentIndex
|
||||||
|
let newIndex = state.currentIndex;
|
||||||
|
const currentTrackId = state.queue[state.currentIndex]?.id;
|
||||||
|
|
||||||
|
// If queue changed and we had a current track, try to find it in new queue
|
||||||
|
if (currentTrackId && syncedState.queue) {
|
||||||
|
const newPosition = syncedState.queue.findIndex(t => t.id === currentTrackId);
|
||||||
|
if (newPosition >= 0) {
|
||||||
|
newIndex = newPosition;
|
||||||
|
} else if (newIndex >= syncedState.queue.length) {
|
||||||
|
// Current track was removed, adjust to valid index
|
||||||
|
newIndex = Math.max(0, syncedState.queue.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queue: syncedState.queue ?? state.queue,
|
||||||
|
currentIndex: newIndex,
|
||||||
|
volume: syncedState.volume ?? state.volume,
|
||||||
|
muted: syncedState.muted ?? state.muted,
|
||||||
|
shuffle: syncedState.shuffle ?? state.shuffle,
|
||||||
|
repeat: syncedState.repeat ?? state.repeat,
|
||||||
|
minimized: syncedState.minimized ?? state.minimized,
|
||||||
|
// Keep current playback state for this tab
|
||||||
|
enabled: state.enabled || (syncedState.queue?.length > 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const audioPlaylist = createAudioPlaylistStore();
|
export const audioPlaylist = createAudioPlaylistStore();
|
||||||
|
|
||||||
|
// Cross-tab synchronization: listen for localStorage changes from other tabs
|
||||||
|
if (browser) {
|
||||||
|
window.addEventListener('storage', (event) => {
|
||||||
|
if (event.key === STORAGE_KEY && event.newValue) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.newValue);
|
||||||
|
// Only sync queue-related state, not playback state
|
||||||
|
// This prevents one tab from controlling another's playback
|
||||||
|
audioPlaylist.syncFromStorage({
|
||||||
|
queue: parsed.queue || [],
|
||||||
|
volume: parsed.volume ?? 1.0,
|
||||||
|
muted: parsed.muted ?? false,
|
||||||
|
shuffle: parsed.shuffle ?? false,
|
||||||
|
repeat: parsed.repeat ?? 'none',
|
||||||
|
minimized: parsed.minimized ?? false
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sync audio playlist from storage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Derived store for current track (nowPlaying takes precedence over queue)
|
// Derived store for current track (nowPlaying takes precedence over queue)
|
||||||
export const currentTrack = derived(audioPlaylist, $playlist => {
|
export const currentTrack = derived(audioPlaylist, $playlist => {
|
||||||
// If there's a nowPlaying track, that's the current track
|
// If there's a nowPlaying track, that's the current track
|
||||||
|
|
|
||||||
|
|
@ -687,7 +687,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{realm ? `${$siteSettings.site_title} - ${realm.name}` : $siteSettings.site_title}</title>
|
<title>{realm ? `${$siteSettings.site_title} - ${realm.displayName || realm.name}` : $siteSettings.site_title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -1356,7 +1356,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="offline-placeholder">
|
<div class="offline-placeholder">
|
||||||
<div class="offline-icon">⏸</div>
|
<div class="offline-icon">⏸</div>
|
||||||
<div class="offline-text">{realm.name}</div>
|
<div class="offline-text">{realm.displayName || realm.name}</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="offline-badge">OFFLINE</div>
|
<div class="offline-badge">OFFLINE</div>
|
||||||
|
|
@ -1389,7 +1389,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="offline-placeholder">
|
<div class="offline-placeholder">
|
||||||
<div class="offline-icon">⏸</div>
|
<div class="offline-icon">⏸</div>
|
||||||
<div class="offline-text">{realm.name}</div>
|
<div class="offline-text">{realm.displayName || realm.name}</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="offline-badge">OFFLINE</div>
|
<div class="offline-badge">OFFLINE</div>
|
||||||
|
|
@ -1444,7 +1444,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="offline-placeholder">
|
<div class="offline-placeholder">
|
||||||
<div class="offline-icon">⏸</div>
|
<div class="offline-icon">⏸</div>
|
||||||
<div class="offline-text">{realm.name}</div>
|
<div class="offline-text">{realm.displayName || realm.name}</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="offline-badge">OFFLINE</div>
|
<div class="offline-badge">OFFLINE</div>
|
||||||
|
|
@ -1457,7 +1457,7 @@
|
||||||
<div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
|
<div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
|
||||||
<div class="stream-header">
|
<div class="stream-header">
|
||||||
<div class="header-top">
|
<div class="header-top">
|
||||||
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
|
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
|
||||||
<div class="live-status-badge" class:live={stats.isLive}>
|
<div class="live-status-badge" class:live={stats.isLive}>
|
||||||
<span class="badge-segment">{stats.isLive ? stats.connections : realm.viewerCount} viewers</span>
|
<span class="badge-segment">{stats.isLive ? stats.connections : realm.viewerCount} viewers</span>
|
||||||
{#if stats.isLive && stats.bitrate > 0}
|
{#if stats.isLive && stats.bitrate > 0}
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{realm ? `${$siteSettings.site_title} - ${realm.name} Watch` : $siteSettings.site_title}</title>
|
<title>{realm ? `${$siteSettings.site_title} - ${realm.displayName || realm.name} Watch` : $siteSettings.site_title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -489,7 +489,7 @@
|
||||||
<PlaybackControls
|
<PlaybackControls
|
||||||
{currentTime}
|
{currentTime}
|
||||||
duration={$currentVideo?.durationSeconds || duration}
|
duration={$currentVideo?.durationSeconds || duration}
|
||||||
realmName={realm.name}
|
realmName={realm.displayName || realm.name}
|
||||||
username={realm.username}
|
username={realm.username}
|
||||||
titleColor={realm.titleColor}
|
titleColor={realm.titleColor}
|
||||||
colorCode={realm.colorCode}
|
colorCode={realm.colorCode}
|
||||||
|
|
|
||||||
|
|
@ -2795,7 +2795,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="font-weight: 600;">{realm.name}</div>
|
<div style="font-weight: 600;">{realm.displayName || realm.name}</div>
|
||||||
{#if realm.description}
|
{#if realm.description}
|
||||||
<div style="font-size: 0.85rem; color: var(--gray); margin-top: 0.25rem;">
|
<div style="font-size: 0.85rem; color: var(--gray); margin-top: 0.25rem;">
|
||||||
{realm.description}
|
{realm.description}
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if realm}
|
{:else if realm}
|
||||||
<div class="realm-header">
|
<div class="realm-header">
|
||||||
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
|
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
|
||||||
{#if realm.description}
|
{#if realm.description}
|
||||||
<p class="realm-description">{realm.description}</p>
|
<p class="realm-description">{realm.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if realm}
|
{:else if realm}
|
||||||
<div class="realm-header">
|
<div class="realm-header">
|
||||||
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
|
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
|
||||||
{#if realm.description}
|
{#if realm.description}
|
||||||
<p class="realm-description">{realm.description}</p>
|
<p class="realm-description">{realm.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -368,7 +368,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if realm}
|
{:else if realm}
|
||||||
<div class="realm-header">
|
<div class="realm-header">
|
||||||
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
|
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
|
||||||
{#if realm.description}
|
{#if realm.description}
|
||||||
<p class="realm-description">{realm.description}</p>
|
<p class="realm-description">{realm.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,8 @@
|
||||||
// Server will send 'playing' if we're a player or 'spectating' if game is full
|
// Server will send 'playing' if we're a player or 'spectating' if game is full
|
||||||
console.log('[ChessPage] Opening overlay for match:', matchId);
|
console.log('[ChessPage] Opening overlay for match:', matchId);
|
||||||
gamesOverlay.openGame(matchId, null);
|
gamesOverlay.openGame(matchId, null);
|
||||||
|
// Update URL so refresh will rejoin the game
|
||||||
|
goto(`/games/chess?match=${matchId}`, { replaceState: true, noScroll: true });
|
||||||
} else {
|
} else {
|
||||||
error = `Failed to join: ${joinResult.error}`;
|
error = `Failed to join: ${joinResult.error}`;
|
||||||
}
|
}
|
||||||
|
|
@ -239,6 +241,8 @@
|
||||||
if (joinResult.success) {
|
if (joinResult.success) {
|
||||||
// Don't force mode - let server GAME_STATE determine if spectator or player
|
// Don't force mode - let server GAME_STATE determine if spectator or player
|
||||||
gamesOverlay.openGame(matchId, null);
|
gamesOverlay.openGame(matchId, null);
|
||||||
|
// Update URL so refresh will rejoin as spectator
|
||||||
|
goto(`/games/chess?match=${matchId}`, { replaceState: true, noScroll: true });
|
||||||
} else {
|
} else {
|
||||||
error = `Failed to spectate: ${joinResult.error}`;
|
error = `Failed to spectate: ${joinResult.error}`;
|
||||||
}
|
}
|
||||||
|
|
@ -259,10 +263,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!matchIdFromUrl) {
|
// Don't close overlay on mount - URL param handles state recovery
|
||||||
// Close any stale overlay when visiting lobby without a match param
|
// If there's no match param, the overlay won't be open anyway after refresh
|
||||||
gamesOverlay.closeGame();
|
|
||||||
// Note: Initial loadLobbyData is triggered by the reactive statement when auth finishes loading
|
// Note: Initial loadLobbyData is triggered by the reactive statement when auth finishes loading
|
||||||
|
if (!matchIdFromUrl) {
|
||||||
refreshInterval = setInterval(loadLobbyData, GAMES_POLL_INTERVAL);
|
refreshInterval = setInterval(loadLobbyData, GAMES_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
if (!validateRealmName(newRealmName)) {
|
if (!validateRealmName(newRealmName)) {
|
||||||
error = 'Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only';
|
error = 'Realm name must be 3-30 characters, letters, numbers, and hyphens only';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,7 +281,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRealm(realm) {
|
async function deleteRealm(realm) {
|
||||||
if (!confirm(`Delete realm "${realm.name}"? This action cannot be undone.`)) {
|
if (!confirm(`Delete realm "${realm.displayName || realm.name}"? This action cannot be undone.`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,7 +333,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRealmName(name) {
|
function validateRealmName(name) {
|
||||||
return /^[a-z0-9-]{3,30}$/.test(name);
|
// Allow uppercase letters - URL will be lowercase
|
||||||
|
return /^[a-zA-Z0-9-]{3,30}$/.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text) {
|
function copyToClipboard(text) {
|
||||||
|
|
@ -2777,10 +2778,10 @@
|
||||||
{(realm.type || 'stream') === 'stream' ? 'Stream' : realm.type === 'video' ? 'Video' : realm.type === 'audio' ? 'Audio' : realm.type === 'watch' ? 'Watch' : 'Ebook'}
|
{(realm.type || 'stream') === 'stream' ? 'Stream' : realm.type === 'video' ? 'Video' : realm.type === 'audio' ? 'Audio' : realm.type === 'watch' ? 'Watch' : 'Ebook'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--gray);">Lowercase letters, numbers, and hyphens only (3-30 chars)</p>
|
<p style="margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--gray);">Letters, numbers, and hyphens only (3-30 chars). URL will be lowercase.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<h3>
|
<h3>
|
||||||
<span style="cursor: pointer; color: {realm.titleColor || '#ffffff'};" on:click={() => startEditName(realm)} title="Click to rename">{realm.name}</span>
|
<span style="cursor: pointer; color: {realm.titleColor || '#ffffff'};" on:click={() => startEditName(realm)} title="Click to rename">{realm.displayName || realm.name}</span>
|
||||||
<button class="btn" style="padding: 0.15rem 0.5rem; font-size: 0.7rem; margin-left: 0.5rem;" on:click={() => startEditName(realm)}>Rename</button>
|
<button class="btn" style="padding: 0.15rem 0.5rem; font-size: 0.7rem; margin-left: 0.5rem;" on:click={() => startEditName(realm)}>Rename</button>
|
||||||
<span class="realm-type-badge {realm.type || 'stream'}">
|
<span class="realm-type-badge {realm.type || 'stream'}">
|
||||||
{(realm.type || 'stream') === 'stream' ? 'Stream' : realm.type === 'video' ? 'Video' : realm.type === 'audio' ? 'Audio' : realm.type === 'watch' ? 'Watch' : 'Ebook'}
|
{(realm.type || 'stream') === 'stream' ? 'Stream' : realm.type === 'video' ? 'Video' : realm.type === 'audio' ? 'Audio' : realm.type === 'watch' ? 'Watch' : 'Ebook'}
|
||||||
|
|
|
||||||
|
|
@ -772,7 +772,7 @@
|
||||||
<span class="live-badge">LIVE</span>
|
<span class="live-badge">LIVE</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<h4 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h4>
|
<h4 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h4>
|
||||||
<div class="realm-meta">
|
<div class="realm-meta">
|
||||||
{#if realm.type === 'video'}
|
{#if realm.type === 'video'}
|
||||||
<span>{realm.videoCount || 0} videos</span>
|
<span>{realm.videoCount || 0} videos</span>
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if realm}
|
{:else if realm}
|
||||||
<div class="realm-header">
|
<div class="realm-header">
|
||||||
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
|
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
|
||||||
{#if realm.description}
|
{#if realm.description}
|
||||||
<p class="realm-description">{realm.description}</p>
|
<p class="realm-description">{realm.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if realm}
|
{:else if realm}
|
||||||
<div class="realm-header">
|
<div class="realm-header">
|
||||||
<h1>{realm.name}</h1>
|
<h1>{realm.displayName || realm.name}</h1>
|
||||||
{#if realm.description}
|
{#if realm.description}
|
||||||
<p class="realm-description">{realm.description}</p>
|
<p class="realm-description">{realm.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue