✨ feat: 1. Add database migration function. 2. Add download speed statistics persistence and area chart display.
This commit is contained in:
parent
15c83639a7
commit
2de0e63c9a
33 changed files with 1932 additions and 103 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.core.lang.Version;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
|
|
@ -9,11 +10,12 @@ import io.vertx.core.Promise;
|
|||
import io.vertx.jdbcclient.JDBCConnectOptions;
|
||||
import io.vertx.jdbcclient.JDBCPool;
|
||||
import io.vertx.sqlclient.PoolOptions;
|
||||
import telegram.files.repository.FileRepository;
|
||||
import telegram.files.repository.SettingRepository;
|
||||
import telegram.files.repository.TelegramRepository;
|
||||
import io.vertx.sqlclient.SqlConnection;
|
||||
import org.jooq.lambda.tuple.Tuple;
|
||||
import telegram.files.repository.*;
|
||||
import telegram.files.repository.impl.FileRepositoryImpl;
|
||||
import telegram.files.repository.impl.SettingRepositoryImpl;
|
||||
import telegram.files.repository.impl.StatisticRepositoryImpl;
|
||||
import telegram.files.repository.impl.TelegramRepositoryImpl;
|
||||
|
||||
import java.io.File;
|
||||
|
|
@ -31,6 +33,8 @@ public class DataVerticle extends AbstractVerticle {
|
|||
|
||||
public static SettingRepository settingRepository;
|
||||
|
||||
public static StatisticRepository statisticRepository;
|
||||
|
||||
public void start(Promise<Void> stopPromise) {
|
||||
pool = JDBCPool.pool(vertx,
|
||||
new JDBCConnectOptions()
|
||||
|
|
@ -38,14 +42,26 @@ public class DataVerticle extends AbstractVerticle {
|
|||
,
|
||||
new PoolOptions().setMaxSize(16).setName("pool-tf")
|
||||
);
|
||||
settingRepository = new SettingRepositoryImpl(pool);
|
||||
telegramRepository = new TelegramRepositoryImpl(pool);
|
||||
fileRepository = new FileRepositoryImpl(pool);
|
||||
settingRepository = new SettingRepositoryImpl(pool);
|
||||
Future.all(List.of(
|
||||
telegramRepository.init(),
|
||||
fileRepository.init(),
|
||||
settingRepository.init()
|
||||
))
|
||||
statisticRepository = new StatisticRepositoryImpl(pool);
|
||||
List<Definition> definitions = List.of(
|
||||
new SettingRecord.SettingRecordDefinition(),
|
||||
new TelegramRecord.TelegramRecordDefinition(),
|
||||
new FileRecord.FileRecordDefinition(),
|
||||
new StatisticRecord.StatisticRecordDefinition()
|
||||
);
|
||||
pool.getConnection()
|
||||
.compose(conn -> Future.all(definitions.stream().map(d -> d.createTable(conn)).toList()).map(conn))
|
||||
.compose(conn -> settingRepository.<Version>getByKey(SettingKey.version).map(v -> Tuple.tuple(conn, v)))
|
||||
.compose(tuple -> {
|
||||
SqlConnection conn = tuple.v1;
|
||||
Version version = tuple.v2 == null ? new Version("0.0.0") : tuple.v2;
|
||||
return Future.all(definitions.stream().map(d -> d.migrate(conn, version, new Version(Start.VERSION))).toList());
|
||||
})
|
||||
.compose(r ->
|
||||
settingRepository.createOrUpdate(SettingKey.version.name(), Start.VERSION))
|
||||
.onSuccess(r -> {
|
||||
log.info("Database initialized");
|
||||
stopPromise.complete();
|
||||
|
|
|
|||
|
|
@ -398,12 +398,19 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
ctx.fail(400);
|
||||
return;
|
||||
}
|
||||
getTelegramVerticle(telegramId)
|
||||
.ifPresentOrElse(telegramVerticle ->
|
||||
telegramVerticle.getDownloadStatistics()
|
||||
.onSuccess(ctx::json)
|
||||
.onFailure(ctx::fail),
|
||||
() -> ctx.fail(404));
|
||||
Optional<TelegramVerticle> telegramVerticleOptional = getTelegramVerticle(telegramId);
|
||||
if (telegramVerticleOptional.isEmpty()) {
|
||||
ctx.fail(404);
|
||||
return;
|
||||
}
|
||||
TelegramVerticle telegramVerticle = telegramVerticleOptional.get();
|
||||
|
||||
String type = ctx.request().getParam("type");
|
||||
String timeRange = ctx.request().getParam("timeRange");
|
||||
(Objects.equals(type, "phase") ? telegramVerticle. getDownloadStatisticsByPhase(Convert.toInt(timeRange, 1)) :
|
||||
telegramVerticle.getDownloadStatistics())
|
||||
.onSuccess(ctx::json)
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleTelegramChange(RoutingContext ctx) {
|
||||
|
|
|
|||
13
api/src/main/java/telegram/files/MessyUtils.java
Normal file
13
api/src/main/java/telegram/files/MessyUtils.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package telegram.files;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public class MessyUtils {
|
||||
|
||||
public static LocalDateTime withGrouping5Minutes(LocalDateTime time) {
|
||||
int minute = time.getMinute();
|
||||
int minuteGroup = minute / 5;
|
||||
int newMinute = minuteGroup * 5;
|
||||
return time.withMinute(newMinute).withSecond(0).withNano(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import java.util.concurrent.CountDownLatch;
|
|||
public class Start {
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
public static final String VERSION = "0.1.6";
|
||||
public static final String VERSION = "0.1.7";
|
||||
|
||||
private static final CountDownLatch shutdownLatch = new CountDownLatch(1);
|
||||
|
||||
|
|
|
|||
|
|
@ -276,7 +276,9 @@ public class TdApiHelp {
|
|||
Base64.encode((byte[]) BeanUtil.getProperty(content, "photo.minithumbnail.data")),
|
||||
content.caption.text,
|
||||
null,
|
||||
"idle"
|
||||
"idle",
|
||||
System.currentTimeMillis(),
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -329,7 +331,9 @@ public class TdApiHelp {
|
|||
Base64.encode((byte[]) BeanUtil.getProperty(content, "video.minithumbnail.data")),
|
||||
content.caption.text,
|
||||
null,
|
||||
"idle"
|
||||
"idle",
|
||||
System.currentTimeMillis(),
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -369,7 +373,9 @@ public class TdApiHelp {
|
|||
Base64.encode((byte[]) BeanUtil.getProperty(content, "audio.albumCoverMinithumbnail.data")),
|
||||
content.caption.text,
|
||||
null,
|
||||
"idle"
|
||||
"idle",
|
||||
System.currentTimeMillis(),
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -409,7 +415,9 @@ public class TdApiHelp {
|
|||
Base64.encode((byte[]) BeanUtil.getProperty(content, "document.minithumbnail.data")),
|
||||
content.caption.text,
|
||||
null,
|
||||
"idle"
|
||||
"idle",
|
||||
System.currentTimeMillis(),
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ package telegram.files;
|
|||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.codec.Base64;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.date.DateField;
|
||||
import cn.hutool.core.date.DatePattern;
|
||||
import cn.hutool.core.date.DateTime;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
|
|
@ -100,6 +103,7 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
telegramUpdateHandler.setOnFileDownloadsUpdated(this::onFileDownloadsUpdated);
|
||||
telegramUpdateHandler.setOnMessageReceived(this::onMessageReceived);
|
||||
client = Client.create(telegramUpdateHandler, this::handleException, this::handleException);
|
||||
vertx.setPeriodic(60 * 1000, id -> handleSaveAvgSpeed());
|
||||
this.enableProxy(this.proxyName)
|
||||
.onSuccess(r -> startPromise.complete())
|
||||
.onFailure(startPromise::fail);
|
||||
|
|
@ -474,6 +478,72 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
});
|
||||
}
|
||||
|
||||
public Future<JsonObject> getDownloadStatisticsByPhase(Integer timeRange) {
|
||||
// 1: 1 hour, 2: 1 day, 3: 1 week, 4: 1 month
|
||||
long endTime = System.currentTimeMillis();
|
||||
long startTime = switch (timeRange) {
|
||||
case 1 -> DateUtil.offsetHour(DateUtil.date(), -1).getTime();
|
||||
case 2 -> DateUtil.offsetDay(DateUtil.date(), -1).getTime();
|
||||
case 3 -> DateUtil.offsetWeek(DateUtil.date(), -1).getTime();
|
||||
case 4 -> DateUtil.offsetMonth(DateUtil.date(), -1).getTime();
|
||||
default -> throw new IllegalStateException("Unexpected value: " + timeRange);
|
||||
};
|
||||
|
||||
return Future.all(
|
||||
DataVerticle.statisticRepository.getRangeStatistics(StatisticRecord.Type.speed, this.telegramRecord.id(), startTime, endTime)
|
||||
.map(statisticRecords -> {
|
||||
TreeMap<String, List<JsonObject>> groupedSpeedStats = new TreeMap<>(Comparator.comparing(
|
||||
switch (timeRange) {
|
||||
case 1, 2 -> (Function<? super String, ? extends DateTime>) time ->
|
||||
DateUtil.parse(time, DatePattern.NORM_DATETIME_MINUTE_FORMAT);
|
||||
case 3, 4 -> DateUtil::parseDate;
|
||||
default -> throw new IllegalStateException("Unexpected value: " + timeRange);
|
||||
}
|
||||
));
|
||||
for (StatisticRecord record : statisticRecords) {
|
||||
JsonObject data = new JsonObject(record.data());
|
||||
long timestamp = record.timestamp();
|
||||
String time = switch (timeRange) {
|
||||
case 1 ->
|
||||
MessyUtils.withGrouping5Minutes(DateUtil.toLocalDateTime(DateUtil.date(timestamp))).format(DatePattern.NORM_DATETIME_MINUTE_FORMATTER);
|
||||
case 2 ->
|
||||
DateUtil.date(timestamp).setField(DateField.MINUTE, 0).toString(DatePattern.NORM_DATETIME_MINUTE_FORMAT);
|
||||
case 3, 4 ->
|
||||
DateUtil.date(timestamp).setField(DateField.MINUTE, 0).toString(DatePattern.NORM_DATE_FORMAT);
|
||||
default -> throw new IllegalStateException("Unexpected value: " + timeRange);
|
||||
};
|
||||
groupedSpeedStats.computeIfAbsent(time, k -> new ArrayList<>()).add(data);
|
||||
}
|
||||
return groupedSpeedStats.entrySet().stream()
|
||||
.map(entry -> {
|
||||
JsonObject speedStat = entry.getValue().stream().reduce(new JsonObject()
|
||||
.put("avgSpeed", 0)
|
||||
.put("medianSpeed", 0)
|
||||
.put("maxSpeed", 0)
|
||||
.put("minSpeed", Long.MAX_VALUE),
|
||||
(a, b) -> new JsonObject()
|
||||
.put("avgSpeed", a.getLong("avgSpeed") + b.getLong("avgSpeed"))
|
||||
.put("medianSpeed", a.getLong("medianSpeed") + b.getLong("medianSpeed"))
|
||||
.put("maxSpeed", Math.max(a.getLong("maxSpeed"), b.getLong("maxSpeed")))
|
||||
.put("minSpeed", Math.min(a.getLong("minSpeed"), b.getLong("minSpeed")))
|
||||
);
|
||||
int size = entry.getValue().size();
|
||||
speedStat.put("avgSpeed", speedStat.getLong("avgSpeed") / size)
|
||||
.put("medianSpeed", speedStat.getLong("medianSpeed") / size);
|
||||
return new JsonObject()
|
||||
.put("time", entry.getKey())
|
||||
.put("data", speedStat);
|
||||
})
|
||||
.toList();
|
||||
}),
|
||||
DataVerticle.fileRepository.getCompletedRangeStatistics(this.telegramRecord.id(), startTime, endTime, timeRange)
|
||||
)
|
||||
.map(r -> new JsonObject()
|
||||
.put("speedStats", r.resultAt(0))
|
||||
.put("completedStats", r.resultAt(1))
|
||||
);
|
||||
}
|
||||
|
||||
public Future<TdApi.Proxy> enableProxy(String proxyName) {
|
||||
if (StrUtil.isBlank(proxyName)) return Future.succeededFuture();
|
||||
return DataVerticle.settingRepository.<SettingProxyRecords>getByKey(SettingKey.proxys)
|
||||
|
|
@ -625,6 +695,23 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
log.error(e);
|
||||
}
|
||||
|
||||
private void handleSaveAvgSpeed() {
|
||||
if (!authorized || telegramRecord == null) return;
|
||||
AvgSpeed.SpeedStats speedStats = avgSpeed.getSpeedStats();
|
||||
if (speedStats.avgSpeed() == 0
|
||||
&& speedStats.minSpeed() == 0
|
||||
&& speedStats.medianSpeed() == 0
|
||||
&& speedStats.maxSpeed() == 0) {
|
||||
return;
|
||||
}
|
||||
JsonObject data = JsonObject.mapFrom(speedStats);
|
||||
data.remove("interval");
|
||||
DataVerticle.statisticRepository.create(new StatisticRecord(Convert.toStr(telegramRecord.id()),
|
||||
StatisticRecord.Type.speed,
|
||||
System.currentTimeMillis(),
|
||||
data.encode()));
|
||||
}
|
||||
|
||||
private void onAuthorizationStateUpdated(TdApi.AuthorizationState authorizationState) {
|
||||
log.debug("[%s] Receive authorization state update: %s".formatted(getRootId(), authorizationState));
|
||||
this.lastAuthorizationState = authorizationState;
|
||||
|
|
@ -695,20 +782,24 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
TdApi.File file = updateFile.file;
|
||||
if (file != null) {
|
||||
String localPath = null;
|
||||
Long completionDate = null;
|
||||
if (file.local != null && file.local.isDownloadingCompleted) {
|
||||
localPath = file.local.path;
|
||||
completionDate = System.currentTimeMillis();
|
||||
}
|
||||
FileRecord.DownloadStatus downloadStatus = TdApiHelp.getDownloadStatus(file);
|
||||
DataVerticle.fileRepository.updateStatus(file.id,
|
||||
file.remote.uniqueId,
|
||||
localPath,
|
||||
downloadStatus)
|
||||
downloadStatus,
|
||||
completionDate)
|
||||
.onSuccess(r -> {
|
||||
if (r == null || r.isEmpty()) return;
|
||||
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
|
||||
.put("fileId", file.id)
|
||||
.put("downloadStatus", r.getString("downloadStatus"))
|
||||
.put("localPath", r.getString("localPath"))
|
||||
.put("completionDate", r.getLong("completionDate"))
|
||||
));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
47
api/src/main/java/telegram/files/repository/Definition.java
Normal file
47
api/src/main/java/telegram/files/repository/Definition.java
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import cn.hutool.core.lang.Version;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.sqlclient.SqlConnection;
|
||||
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public interface Definition {
|
||||
|
||||
Log log = LogFactory.get();
|
||||
|
||||
String getScheme();
|
||||
|
||||
default TreeMap<Version, String[]> getMigrations() {
|
||||
return new TreeMap<>();
|
||||
}
|
||||
|
||||
default Future<Void> createTable(SqlConnection conn) {
|
||||
return conn
|
||||
.query(getScheme())
|
||||
.execute()
|
||||
.onFailure(err -> log.error("Failed to create table: %s".formatted(err.getMessage())))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
default Future<Void> migrate(SqlConnection conn, Version lastVersion, Version currentVersion) {
|
||||
TreeMap<Version, String[]> migrations = getMigrations();
|
||||
if (migrations.isEmpty()) {
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
return Future.all(migrations.subMap(lastVersion, false, currentVersion, true).values()
|
||||
.stream()
|
||||
.flatMap(arr -> Stream.of(arr)
|
||||
.map(sql -> conn.query(sql)
|
||||
.execute()
|
||||
.onFailure(e -> log.error("Failed to apply migration: %s".formatted(sql), e)))
|
||||
)
|
||||
.toList()
|
||||
)
|
||||
.onFailure(err -> log.error("Failed to migrate table: %s".formatted(err.getMessage())))
|
||||
.mapEmpty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.lang.Version;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import io.vertx.sqlclient.templates.RowMapper;
|
||||
import io.vertx.sqlclient.templates.TupleMapper;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
|
||||
public record FileRecord(int id, //file id will change
|
||||
String uniqueId, // unique id of the file, if empty, it means the file is cant be downloaded
|
||||
long telegramId,
|
||||
|
|
@ -20,7 +24,9 @@ public record FileRecord(int id, //file id will change
|
|||
String thumbnail,
|
||||
String caption,
|
||||
String localPath,
|
||||
String downloadStatus // 'idle' | 'downloading' | 'paused' | 'completed' | 'error'
|
||||
String downloadStatus, // 'idle' | 'downloading' | 'paused' | 'completed' | 'error'
|
||||
long startDate, // date when the file was started to download
|
||||
Long completionDate // date when the file was downloaded
|
||||
) {
|
||||
|
||||
public enum DownloadStatus {
|
||||
|
|
@ -46,10 +52,31 @@ public record FileRecord(int id, //file id will change
|
|||
caption VARCHAR(255),
|
||||
local_path VARCHAR(255),
|
||||
download_status VARCHAR(255),
|
||||
start_date BIGINT,
|
||||
completion_date BIGINT,
|
||||
PRIMARY KEY (id, unique_id)
|
||||
)
|
||||
""";
|
||||
|
||||
public static final TreeMap<Version, String[]> MIGRATIONS = new TreeMap<>(MapUtil.ofEntries(
|
||||
MapUtil.entry(new Version("0.1.7"), new String[]{
|
||||
"ALTER TABLE file_record ADD COLUMN start_date BIGINT;",
|
||||
"ALTER TABLE file_record ADD COLUMN completion_date BIGINT;",
|
||||
})
|
||||
));
|
||||
|
||||
public static class FileRecordDefinition implements Definition {
|
||||
@Override
|
||||
public String getScheme() {
|
||||
return SCHEME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TreeMap<Version, String[]> getMigrations() {
|
||||
return MIGRATIONS;
|
||||
}
|
||||
}
|
||||
|
||||
public static RowMapper<FileRecord> ROW_MAPPER = row ->
|
||||
new FileRecord(row.getInteger("id"),
|
||||
row.getString("unique_id"),
|
||||
|
|
@ -66,7 +93,9 @@ public record FileRecord(int id, //file id will change
|
|||
row.getString("thumbnail"),
|
||||
row.getString("caption"),
|
||||
row.getString("local_path"),
|
||||
row.getString("download_status")
|
||||
row.getString("download_status"),
|
||||
Objects.requireNonNullElse(row.getLong("start_date"), 0L),
|
||||
row.getLong("completion_date")
|
||||
);
|
||||
|
||||
public static TupleMapper<FileRecord> PARAM_MAPPER = TupleMapper.mapper(r ->
|
||||
|
|
@ -86,11 +115,13 @@ public record FileRecord(int id, //file id will change
|
|||
MapUtil.entry("thumbnail", r.thumbnail()),
|
||||
MapUtil.entry("caption", r.caption()),
|
||||
MapUtil.entry("local_path", r.localPath()),
|
||||
MapUtil.entry("download_status", r.downloadStatus())
|
||||
MapUtil.entry("download_status", r.downloadStatus()),
|
||||
MapUtil.entry("start_date", r.startDate()),
|
||||
MapUtil.entry("completion_date", r.completionDate())
|
||||
));
|
||||
|
||||
public FileRecord withSourceField(int id, long downloadedSize) {
|
||||
return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus);
|
||||
return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus, startDate, completionDate);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package telegram.files.repository;
|
|||
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.jooq.lambda.tuple.Tuple3;
|
||||
|
||||
|
|
@ -9,8 +10,6 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
|
||||
public interface FileRepository {
|
||||
Future<Void> init();
|
||||
|
||||
Future<FileRecord> create(FileRecord fileRecord);
|
||||
|
||||
Future<Map<Integer, FileRecord>> getFiles(long chatId, List<Integer> fileIds);
|
||||
|
|
@ -25,15 +24,17 @@ public interface FileRepository {
|
|||
|
||||
Future<JsonObject> getDownloadStatistics(long telegramId);
|
||||
|
||||
Future<JsonArray> getCompletedRangeStatistics(long id, long startTime, long endTime, int timeRange);
|
||||
|
||||
Future<Integer> countByStatus(long telegramId, FileRecord.DownloadStatus downloadStatus);
|
||||
|
||||
Future<JsonObject> updateStatus(int fileId,
|
||||
String uniqueId,
|
||||
String localPath,
|
||||
FileRecord.DownloadStatus downloadStatus);
|
||||
FileRecord.DownloadStatus downloadStatus,
|
||||
Long completionDate);
|
||||
|
||||
Future<Void> updateFileId(int fileId, String uniqueId);
|
||||
|
||||
Future<Void> deleteByUniqueId(String uniqueId);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.lang.Version;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
public enum SettingKey {
|
||||
version(Version::new),
|
||||
uniqueOnly(Convert::toBool, false),
|
||||
needToLoadImages(Convert::toBool, false),
|
||||
imageLoadSize,
|
||||
|
|
@ -17,6 +19,10 @@ public enum SettingKey {
|
|||
*/
|
||||
autoDownloadLimit(Convert::toInt),
|
||||
proxys(value -> new JsonObject(value).mapTo(SettingProxyRecords.class)),
|
||||
/**
|
||||
* Interval for calculating average speed, in seconds
|
||||
*/
|
||||
avgSpeedInterval(Convert::toInt, 5 * 60),
|
||||
;
|
||||
|
||||
public final Function<String, ?> converter;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ public record SettingRecord(String key, String value) {
|
|||
)
|
||||
""";
|
||||
|
||||
public static class SettingRecordDefinition implements Definition {
|
||||
@Override
|
||||
public String getScheme() {
|
||||
return SCHEME;
|
||||
}
|
||||
}
|
||||
|
||||
public static RowMapper<SettingRecord> ROW_MAPPER = row ->
|
||||
new SettingRecord(row.getString("key"),
|
||||
row.getString("value")
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import io.vertx.core.Future;
|
|||
import java.util.List;
|
||||
|
||||
public interface SettingRepository {
|
||||
|
||||
Future<Void> init();
|
||||
|
||||
Future<SettingRecord> createOrUpdate(String key, String value);
|
||||
|
||||
Future<List<SettingRecord>> getByKeys(List<String> keys);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import io.vertx.sqlclient.templates.RowMapper;
|
||||
import io.vertx.sqlclient.templates.TupleMapper;
|
||||
|
||||
public record StatisticRecord(
|
||||
String relatedId,
|
||||
Type type,
|
||||
long timestamp,
|
||||
String data) {
|
||||
|
||||
public enum Type {
|
||||
speed,
|
||||
|
||||
;
|
||||
}
|
||||
|
||||
public static final String SCHEME = """
|
||||
CREATE TABLE IF NOT EXISTS statistic_record
|
||||
(
|
||||
related_id VARCHAR(255),
|
||||
type VARCHAR(255),
|
||||
timestamp BIGINT,
|
||||
data TEXT
|
||||
)
|
||||
""";
|
||||
|
||||
public static class StatisticRecordDefinition implements Definition {
|
||||
@Override
|
||||
public String getScheme() {
|
||||
return SCHEME;
|
||||
}
|
||||
}
|
||||
|
||||
public static RowMapper<StatisticRecord> ROW_MAPPER = row ->
|
||||
new StatisticRecord(row.getString("related_id"),
|
||||
Type.valueOf(row.getString("type")),
|
||||
row.getLong("timestamp"),
|
||||
row.getString("data")
|
||||
);
|
||||
|
||||
public static TupleMapper<StatisticRecord> PARAM_MAPPER = TupleMapper.mapper(r ->
|
||||
MapUtil.ofEntries(MapUtil.entry("related_id", r.relatedId),
|
||||
MapUtil.entry("type", r.type().name()),
|
||||
MapUtil.entry("timestamp", r.timestamp()),
|
||||
MapUtil.entry("data", r.data())
|
||||
));
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package telegram.files.repository;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface StatisticRepository {
|
||||
Future<Void> create(StatisticRecord record);
|
||||
|
||||
Future<List<StatisticRecord>> getRangeStatistics(StatisticRecord.Type type,
|
||||
long relatedId,
|
||||
long startTime,
|
||||
long endTime);
|
||||
}
|
||||
|
|
@ -21,6 +21,13 @@ public record TelegramRecord(
|
|||
)
|
||||
""";
|
||||
|
||||
public static class TelegramRecordDefinition implements Definition {
|
||||
@Override
|
||||
public String getScheme() {
|
||||
return SCHEME;
|
||||
}
|
||||
}
|
||||
|
||||
public static RowMapper<TelegramRecord> ROW_MAPPER = row ->
|
||||
new TelegramRecord(row.getLong("id"),
|
||||
row.getString("first_name"),
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import io.vertx.core.Future;
|
|||
import java.util.List;
|
||||
|
||||
public interface TelegramRepository {
|
||||
|
||||
Future<Void> init();
|
||||
|
||||
String getRootPath();
|
||||
|
||||
Future<TelegramRecord> create(TelegramRecord telegramRecord);
|
||||
|
|
|
|||
|
|
@ -3,17 +3,21 @@ package telegram.files.repository.impl;
|
|||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.collection.IterUtil;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.date.DatePattern;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.jdbcclient.JDBCPool;
|
||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||
import org.jooq.lambda.tuple.Tuple;
|
||||
import org.jooq.lambda.tuple.Tuple3;
|
||||
import telegram.files.MessyUtils;
|
||||
import telegram.files.repository.FileRecord;
|
||||
import telegram.files.repository.FileRepository;
|
||||
|
||||
|
|
@ -34,19 +38,6 @@ public class FileRepositoryImpl implements FileRepository {
|
|||
this.pool = pool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Void> init() {
|
||||
return pool.getConnection()
|
||||
.compose(conn -> conn
|
||||
.query(FileRecord.SCHEME)
|
||||
.execute()
|
||||
.onComplete(r -> conn.close())
|
||||
.onFailure(err -> log.error("Failed to create table file_record: %s".formatted(err.getMessage())))
|
||||
.onSuccess(ps -> log.trace("Successfully created table: file_record"))
|
||||
)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<FileRecord> create(FileRecord fileRecord) {
|
||||
return SqlTemplate
|
||||
|
|
@ -54,9 +45,9 @@ public class FileRepositoryImpl implements FileRepository {
|
|||
INSERT INTO file_record(id, unique_id, telegram_id, chat_id, message_id, date, has_sensitive_content, size, downloaded_size,
|
||||
type, mime_type,
|
||||
file_name, thumbnail, caption, local_path,
|
||||
download_status)
|
||||
download_status, start_date)
|
||||
values (#{id}, #{unique_id}, #{telegram_id}, #{chat_id}, #{message_id}, #{date}, #{has_sensitive_content}, #{size}, #{downloaded_size}, #{type},
|
||||
#{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status})
|
||||
#{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status}, #{start_date})
|
||||
""")
|
||||
.mapFrom(FileRecord.PARAM_MAPPER)
|
||||
.execute(fileRecord)
|
||||
|
|
@ -235,6 +226,61 @@ public class FileRepositoryImpl implements FileRepository {
|
|||
.onFailure(err -> log.error("Failed to get download statistics: %s".formatted(err.getMessage())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<JsonArray> getCompletedRangeStatistics(long telegramId, long startTime, long endTime, int timeRange) {
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT strftime(
|
||||
CASE
|
||||
WHEN #{timeRange} = 1 THEN '%Y-%m-%d %H:%M'
|
||||
WHEN #{timeRange} = 2 THEN '%Y-%m-%d %H:00'
|
||||
WHEN #{timeRange} IN (3, 4) THEN '%Y-%m-%d'
|
||||
END,
|
||||
datetime(completion_date / 1000, 'unixepoch')
|
||||
) AS time,
|
||||
COUNT(*) AS total
|
||||
FROM file_record
|
||||
WHERE telegram_id = #{telegramId}
|
||||
AND completion_date IS NOT NULL
|
||||
AND completion_date >= #{startTime}
|
||||
AND completion_date <= #{endTime}
|
||||
GROUP BY time
|
||||
ORDER BY time;
|
||||
""")
|
||||
.mapTo(row -> new JsonObject()
|
||||
.put("time", row.getString("time"))
|
||||
.put("total", row.getInteger("total"))
|
||||
)
|
||||
.execute(Map.of("telegramId", telegramId, "startTime", startTime, "endTime", endTime, "timeRange", timeRange))
|
||||
.map(IterUtil::toList)
|
||||
.map(rs -> {
|
||||
if (CollUtil.isEmpty(rs)) {
|
||||
return JsonArray.of();
|
||||
}
|
||||
if (timeRange == 1) {
|
||||
// Statistics grouped by five minutes
|
||||
return rs.stream()
|
||||
.peek(c -> c.put("time", MessyUtils.withGrouping5Minutes(
|
||||
DateUtil.toLocalDateTime(DateUtil.date(Convert.toLong(c.getString("time"), 0L)))
|
||||
).format(DatePattern.NORM_DATETIME_MINUTE_FORMATTER)))
|
||||
.collect(Collectors.groupingBy(c -> c.getString("time"),
|
||||
Collectors.summingInt(c -> c.getInteger("total"))
|
||||
))
|
||||
.entrySet().stream()
|
||||
.map(e -> new JsonObject()
|
||||
.put("time", e.getKey())
|
||||
.put("total", e.getValue())
|
||||
)
|
||||
.collect(JsonArray::new, JsonArray::add, JsonArray::addAll);
|
||||
} else {
|
||||
JsonArray jsonArray = new JsonArray();
|
||||
rs.forEach(jsonArray::add);
|
||||
return jsonArray;
|
||||
}
|
||||
})
|
||||
.onFailure(err -> log.error("Failed to get completed statistics: %s".formatted(err.getMessage())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Integer> countByStatus(long telegramId, FileRecord.DownloadStatus downloadStatus) {
|
||||
return SqlTemplate
|
||||
|
|
@ -248,7 +294,11 @@ public class FileRepositoryImpl implements FileRepository {
|
|||
}
|
||||
|
||||
@Override
|
||||
public Future<JsonObject> updateStatus(int fileId, String uniqueId, String localPath, FileRecord.DownloadStatus downloadStatus) {
|
||||
public Future<JsonObject> updateStatus(int fileId,
|
||||
String uniqueId,
|
||||
String localPath,
|
||||
FileRecord.DownloadStatus downloadStatus,
|
||||
Long completionDate) {
|
||||
if (StrUtil.isBlank(localPath) && downloadStatus == null) {
|
||||
return Future.succeededFuture(null);
|
||||
}
|
||||
|
|
@ -265,13 +315,16 @@ public class FileRepositoryImpl implements FileRepository {
|
|||
|
||||
return SqlTemplate
|
||||
.forUpdate(pool, """
|
||||
UPDATE file_record SET local_path = #{localPath}, download_status = #{downloadStatus}
|
||||
UPDATE file_record SET local_path = #{localPath},
|
||||
download_status = #{downloadStatus},
|
||||
completion_date = #{completionDate}
|
||||
WHERE id = #{fileId} AND unique_id = #{uniqueId}
|
||||
""")
|
||||
.execute(MapUtil.ofEntries(MapUtil.entry("fileId", fileId),
|
||||
MapUtil.entry("uniqueId", uniqueId),
|
||||
MapUtil.entry("localPath", pathUpdated ? localPath : record.localPath()),
|
||||
MapUtil.entry("downloadStatus", downloadStatusUpdated ? downloadStatus.name() : record.downloadStatus())
|
||||
MapUtil.entry("downloadStatus", downloadStatusUpdated ? downloadStatus.name() : record.downloadStatus()),
|
||||
MapUtil.entry("completionDate", completionDate)
|
||||
))
|
||||
.onFailure(err ->
|
||||
log.error("Failed to update file record: %s".formatted(err.getMessage()))
|
||||
|
|
@ -280,6 +333,7 @@ public class FileRepositoryImpl implements FileRepository {
|
|||
JsonObject result = JsonObject.of();
|
||||
if (pathUpdated) {
|
||||
result.put("localPath", localPath);
|
||||
result.put("completionDate", completionDate);
|
||||
}
|
||||
if (downloadStatusUpdated) {
|
||||
result.put("downloadStatus", downloadStatus.name());
|
||||
|
|
|
|||
|
|
@ -27,19 +27,6 @@ public class SettingRepositoryImpl implements SettingRepository {
|
|||
this.pool = pool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Void> init() {
|
||||
return pool.getConnection()
|
||||
.compose(conn -> conn
|
||||
.query(SettingRecord.SCHEME)
|
||||
.execute()
|
||||
.onComplete(r -> conn.close())
|
||||
.onFailure(err -> log.error("Failed to create table setting_record: %s".formatted(err.getMessage())))
|
||||
.onSuccess(ps -> log.trace("Successfully created table: setting_record"))
|
||||
)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<SettingRecord> createOrUpdate(String key, String value) {
|
||||
return SqlTemplate
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package telegram.files.repository.impl;
|
||||
|
||||
import cn.hutool.core.collection.IterUtil;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.jdbcclient.JDBCPool;
|
||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||
import telegram.files.repository.StatisticRecord;
|
||||
import telegram.files.repository.StatisticRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class StatisticRepositoryImpl implements StatisticRepository {
|
||||
|
||||
private static final Log log = LogFactory.get();
|
||||
|
||||
private final JDBCPool pool;
|
||||
|
||||
public StatisticRepositoryImpl(JDBCPool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Void> create(StatisticRecord record) {
|
||||
return SqlTemplate
|
||||
.forUpdate(pool, """
|
||||
INSERT INTO statistic_record(related_id, type, timestamp, data)
|
||||
VALUES (#{related_id}, #{type}, #{timestamp}, #{data})
|
||||
""")
|
||||
.mapFrom(StatisticRecord.PARAM_MAPPER)
|
||||
.execute(record)
|
||||
.onSuccess(r -> log.trace("Successfully created statistic record: %s".formatted(record.relatedId())))
|
||||
.onFailure(
|
||||
err -> log.error("Failed to create statistic record: %s".formatted(err.getMessage()))
|
||||
)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<StatisticRecord>> getRangeStatistics(StatisticRecord.Type type,
|
||||
long relatedId,
|
||||
long startTime,
|
||||
long endTime) {
|
||||
return SqlTemplate
|
||||
.forQuery(pool, """
|
||||
SELECT *
|
||||
FROM statistic_record
|
||||
WHERE type = #{type}
|
||||
AND related_id = #{relatedId}
|
||||
AND timestamp >= #{startTime}
|
||||
AND timestamp <= #{endTime}
|
||||
ORDER BY timestamp
|
||||
""")
|
||||
.mapTo(StatisticRecord.ROW_MAPPER)
|
||||
.execute(Map.of(
|
||||
"type", type.name(),
|
||||
"relatedId", relatedId,
|
||||
"startTime", startTime,
|
||||
"endTime", endTime
|
||||
))
|
||||
.map(IterUtil::toList)
|
||||
.onFailure(
|
||||
err -> log.error("Failed to get range statistics: %s".formatted(err.getMessage()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,19 +26,6 @@ public class TelegramRepositoryImpl implements TelegramRepository {
|
|||
this.pool = pool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Void> init() {
|
||||
return pool.getConnection()
|
||||
.compose(conn -> conn
|
||||
.query(TelegramRecord.SCHEME)
|
||||
.execute()
|
||||
.onComplete(r -> conn.close())
|
||||
.onFailure(err -> log.error("Failed to create table telegram_record: %s".formatted(err.getMessage())))
|
||||
.onSuccess(ps -> log.trace("Successfully created table: telegram_record"))
|
||||
)
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRootPath() {
|
||||
return Config.TELEGRAM_ROOT + File.separator + UUID.randomUUID();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.lang.Version;
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.Vertx;
|
||||
|
|
@ -9,6 +10,7 @@ import io.vertx.junit5.VertxTestContext;
|
|||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import telegram.files.repository.FileRecord;
|
||||
import telegram.files.repository.SettingKey;
|
||||
import telegram.files.repository.TelegramRecord;
|
||||
|
||||
@ExtendWith(VertxExtension.class)
|
||||
|
|
@ -22,8 +24,8 @@ public class DataVerticleTest {
|
|||
log.debug("DATA_PATH:" + DataVerticle.getDataPath());
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
String dataPath = DataVerticle.getDataPath();
|
||||
if (FileUtil.file(dataPath).exists()) {
|
||||
FileUtil.del(dataPath);
|
||||
|
|
@ -37,6 +39,17 @@ public class DataVerticleTest {
|
|||
.onComplete(testContext.succeedingThenComplete());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Check table initialization")
|
||||
void checkTableInitialization(Vertx vertx, VertxTestContext testContext) {
|
||||
DataVerticle.settingRepository.<Version>getByKey(SettingKey.version)
|
||||
.onComplete(testContext.succeeding(r -> testContext.verify(() -> {
|
||||
Assertions.assertNotNull(r);
|
||||
Assertions.assertEquals(new Version(Start.VERSION), r);
|
||||
testContext.completeNow();
|
||||
})));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test Create telegram record")
|
||||
void createTelegramRecordTest(Vertx vertx, VertxTestContext testContext) {
|
||||
|
|
@ -69,7 +82,7 @@ public class DataVerticleTest {
|
|||
@DisplayName("Test Get file record by primary key")
|
||||
void getFileRecordByPrimaryKeyTest(Vertx vertx, VertxTestContext testContext) {
|
||||
FileRecord fileRecord = new FileRecord(
|
||||
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status"
|
||||
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status", 0, null
|
||||
);
|
||||
DataVerticle.fileRepository.create(fileRecord)
|
||||
.compose(r -> DataVerticle.fileRepository.getByPrimaryKey(r.id(), r.uniqueId()))
|
||||
|
|
@ -83,15 +96,17 @@ public class DataVerticleTest {
|
|||
@DisplayName("Test update file status")
|
||||
void updateFileStatusTest(Vertx vertx, VertxTestContext testContext) {
|
||||
FileRecord fileRecord = new FileRecord(
|
||||
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status"
|
||||
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status", 0, null
|
||||
);
|
||||
String updateLocalPath = "local_path";
|
||||
Long completionDate = 1L;
|
||||
DataVerticle.fileRepository.create(fileRecord)
|
||||
.compose(r -> DataVerticle.fileRepository.updateStatus(r.id(), r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading))
|
||||
.compose(r -> DataVerticle.fileRepository.updateStatus(r.id(), r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading, completionDate))
|
||||
.compose(r -> {
|
||||
testContext.verify(() -> {
|
||||
Assertions.assertEquals(updateLocalPath, r.getString("localPath"));
|
||||
Assertions.assertEquals(FileRecord.DownloadStatus.downloading.name(), r.getString("downloadStatus"));
|
||||
Assertions.assertEquals(completionDate, r.getLong("completionDate"));
|
||||
});
|
||||
return DataVerticle.fileRepository.getByPrimaryKey(fileRecord.id(), fileRecord.uniqueId());
|
||||
})
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ GET http://{{server}}//file/preview?chatId={{chatId}}&messageId={{messageId}}
|
|||
Cookie: {{tf}}
|
||||
|
||||
### Get download statistics
|
||||
GET http://{{server}}/telegram/{{telegramId}}/download-statistics
|
||||
GET http://{{server}}/telegram/{{telegramId}}/download-statistics?type=phase&timeRange=3
|
||||
Cookie: {{tf}}
|
||||
|
||||
### Telegram GetMe
|
||||
|
|
|
|||
590
web/package-lock.json
generated
590
web/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "telegram-files-web",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "telegram-files-web",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.2.0",
|
||||
"@dnd-kit/modifiers": "^8.0.0",
|
||||
|
|
@ -35,12 +35,14 @@
|
|||
"input-otp": "^1.4.1",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.0.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"recharts": "^2.15.0",
|
||||
"spoiled": "^0.3.2",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
|
|
@ -2160,6 +2162,60 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
|
||||
"integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "8.56.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
|
||||
|
|
@ -3364,8 +3420,117 @@
|
|||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
|
|
@ -3450,6 +3615,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -3526,6 +3696,15 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
|
|
@ -4210,12 +4389,25 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.0.tgz",
|
||||
"integrity": "sha512-3VpaQYf+CDFdRQfgsb+3vY7XaKjM35WCMoQTTE8h4S/eUkHzyJFOOA/gATYgoLejy4FBrEQD/sXe5Auk4cW/AQ==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||
|
|
@ -4752,6 +4944,14 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
|
||||
|
|
@ -5313,6 +5513,11 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
|
@ -5411,6 +5616,31 @@
|
|||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "11.15.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-11.15.0.tgz",
|
||||
"integrity": "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==",
|
||||
"dependencies": {
|
||||
"framer-motion": "^11.15.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.14.3",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz",
|
||||
|
|
@ -6045,7 +6275,6 @@
|
|||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
|
|
@ -6121,8 +6350,7 @@
|
|||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.6.0",
|
||||
|
|
@ -6169,6 +6397,20 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
|
|
@ -6190,6 +6432,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-use-websocket": {
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.11.1.tgz",
|
||||
|
|
@ -6214,6 +6471,41 @@
|
|||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz",
|
||||
"integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.0",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz",
|
||||
|
|
@ -6982,6 +7274,11 @@
|
|||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -7245,6 +7542,27 @@
|
|||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -8597,6 +8915,60 @@
|
|||
"@t3-oss/env-core": "0.10.1"
|
||||
}
|
||||
},
|
||||
"@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
|
||||
},
|
||||
"@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||
},
|
||||
"@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||
},
|
||||
"@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"requires": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"@types/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="
|
||||
},
|
||||
"@types/d3-scale": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
|
||||
"integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
|
||||
"requires": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"requires": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||
},
|
||||
"@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||
},
|
||||
"@types/eslint": {
|
||||
"version": "8.56.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
|
||||
|
|
@ -9353,8 +9725,84 @@
|
|||
"csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"requires": {
|
||||
"internmap": "1 - 2"
|
||||
}
|
||||
},
|
||||
"d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
|
||||
},
|
||||
"d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
|
||||
},
|
||||
"d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="
|
||||
},
|
||||
"d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"requires": {
|
||||
"d3-color": "1 - 3"
|
||||
}
|
||||
},
|
||||
"d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
|
||||
},
|
||||
"d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"requires": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
}
|
||||
},
|
||||
"d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"requires": {
|
||||
"d3-path": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"requires": {
|
||||
"d3-array": "2 - 3"
|
||||
}
|
||||
},
|
||||
"d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"requires": {
|
||||
"d3-time": "1 - 3"
|
||||
}
|
||||
},
|
||||
"d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
|
||||
},
|
||||
"damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
|
|
@ -9409,6 +9857,11 @@
|
|||
"ms": "^2.1.3"
|
||||
}
|
||||
},
|
||||
"decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||
},
|
||||
"deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -9467,6 +9920,15 @@
|
|||
"esutils": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
|
|
@ -9989,12 +10451,22 @@
|
|||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"dev": true
|
||||
},
|
||||
"eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-equals": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.0.tgz",
|
||||
"integrity": "sha512-3VpaQYf+CDFdRQfgsb+3vY7XaKjM35WCMoQTTE8h4S/eUkHzyJFOOA/gATYgoLejy4FBrEQD/sXe5Auk4cW/AQ=="
|
||||
},
|
||||
"fast-glob": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||
|
|
@ -10376,6 +10848,11 @@
|
|||
"side-channel": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="
|
||||
},
|
||||
"is-array-buffer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
|
||||
|
|
@ -10758,6 +11235,11 @@
|
|||
"p-locate": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
|
@ -10829,6 +11311,15 @@
|
|||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
|
||||
},
|
||||
"motion": {
|
||||
"version": "11.15.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-11.15.0.tgz",
|
||||
"integrity": "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==",
|
||||
"requires": {
|
||||
"framer-motion": "^11.15.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"motion-dom": {
|
||||
"version": "11.14.3",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz",
|
||||
|
|
@ -11175,7 +11666,6 @@
|
|||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
|
|
@ -11219,8 +11709,7 @@
|
|||
"react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"react-remove-scroll": {
|
||||
"version": "2.6.0",
|
||||
|
|
@ -11243,6 +11732,16 @@
|
|||
"tslib": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"requires": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
}
|
||||
},
|
||||
"react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
|
|
@ -11252,6 +11751,17 @@
|
|||
"tslib": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
}
|
||||
},
|
||||
"react-use-websocket": {
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.11.1.tgz",
|
||||
|
|
@ -11273,6 +11783,36 @@
|
|||
"picomatch": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"recharts": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz",
|
||||
"integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==",
|
||||
"requires": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.0",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"requires": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"reflect.getprototypeof": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz",
|
||||
|
|
@ -11790,6 +12330,11 @@
|
|||
"thenify": ">= 3.1.0 < 4"
|
||||
}
|
||||
},
|
||||
"tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||
},
|
||||
"to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -11970,6 +12515,27 @@
|
|||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"requires": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -43,12 +43,14 @@
|
|||
"input-otp": "^1.4.1",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.0.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"recharts": "^2.15.0",
|
||||
"spoiled": "^0.3.2",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { type TelegramFile } from "@/lib/types";
|
||||
import {
|
||||
Calendar,
|
||||
Clock10,
|
||||
ClockArrowDown,
|
||||
Download,
|
||||
FileAudio2Icon,
|
||||
FileIcon,
|
||||
|
|
@ -183,7 +185,7 @@ function FileDrawer({
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Date</span>
|
||||
<span>Received At</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatDistanceToNow(new Date(file.date * 1000), {
|
||||
|
|
@ -201,6 +203,34 @@ function FileDrawer({
|
|||
{prettyBytes(file.downloadedSize)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{(file.downloadStatus !== 'idle' && file.startDate !== 0) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock10 className="h-4 w-4" />
|
||||
<span>Download At</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatDistanceToNow(new Date(file.startDate), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file.completionDate && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ClockArrowDown className="h-4 w-4" />
|
||||
<span>Completion At</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatDistanceToNow(new Date(file.completionDate), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Captions, Clock, Copy, FileCheck } from "lucide-react";
|
||||
import { Captions, Clock, ClockArrowDown, Copy, FileCheck } from "lucide-react";
|
||||
import SpoiledWrapper from "@/components/spoiled-wrapper";
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -63,9 +63,9 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100">
|
||||
|
|
@ -81,6 +81,25 @@ export default function FileExtra({ file, rowHeight }: FileExtraProps) {
|
|||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{file.completionDate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="flex items-center gap-2">
|
||||
<ClockArrowDown className="h-4 w-4" />
|
||||
<span className="rounded px-1 text-sm text-muted-foreground hover:bg-gray-100">
|
||||
{formatDistanceToNow(new Date(file.completionDate), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="max-w-80 text-wrap rounded p-2">
|
||||
{`File downloaded at ${new Date(file.completionDate).toLocaleString()}`}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
|
|||
249
web/src/components/file-phase-statistics.tsx
Normal file
249
web/src/components/file-phase-statistics.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import React, { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { request } from "@/lib/api";
|
||||
import prettyBytes from "pretty-bytes"; // Type definitions
|
||||
|
||||
// Type definitions
|
||||
type TimeRange = "1" | "2" | "3" | "4";
|
||||
|
||||
interface SpeedData {
|
||||
avgSpeed: number;
|
||||
medianSpeed: number;
|
||||
maxSpeed: number;
|
||||
minSpeed: number;
|
||||
}
|
||||
|
||||
interface SpeedStats {
|
||||
time: string;
|
||||
data: SpeedData;
|
||||
}
|
||||
|
||||
interface CompletedStats {
|
||||
time: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
speedStats: SpeedStats[];
|
||||
completedStats: CompletedStats[];
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string, timeRange: TimeRange): string => {
|
||||
const date = new Date(dateStr);
|
||||
|
||||
switch (timeRange) {
|
||||
case "1": // Last hour
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
case "2": // Last day
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
case "3": // Last week
|
||||
case "4": // Last month
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface TelegramStatsProps {
|
||||
telegramId: string;
|
||||
}
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: "1", label: "1 Hour" },
|
||||
{ value: "2", label: "24 Hours" },
|
||||
{ value: "3", label: "1 Week" },
|
||||
{ value: "4", label: "30 Days" },
|
||||
];
|
||||
|
||||
const axisStyle = {
|
||||
fontSize: 11,
|
||||
fill: "#6b7280", // text-gray-500
|
||||
};
|
||||
|
||||
const TelegramStats: React.FC<TelegramStatsProps> = ({ telegramId }) => {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("2");
|
||||
|
||||
const { data, error, isLoading } = useSWR<ApiResponse, Error>(
|
||||
`/telegram/${telegramId}/download-statistics?type=phase&timeRange=${timeRange}`,
|
||||
request,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4 text-red-500">Failed to load statistics</div>;
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div className="p-4 text-gray-500">Loading statistics...</div>;
|
||||
}
|
||||
|
||||
// Transform speed data for the chart
|
||||
const speedChartData = data.speedStats.map((stat) => ({
|
||||
time: formatDate(stat.time, timeRange),
|
||||
"Average Speed": stat.data.avgSpeed,
|
||||
"Median Speed": stat.data.medianSpeed,
|
||||
"Max Speed": stat.data.maxSpeed,
|
||||
"Min Speed": stat.data.minSpeed,
|
||||
}));
|
||||
|
||||
// Transform completion data for the chart
|
||||
const completionChartData = data.completedStats.map((stat) => ({
|
||||
time: formatDate(stat.time, timeRange),
|
||||
"Completed Downloads": stat.total,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="relative space-y-6">
|
||||
<div className="absolute -top-14 right-1">
|
||||
<Select
|
||||
value={timeRange}
|
||||
onValueChange={(value: TimeRange) => setTimeRange(value)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeRangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="px-1">Download Speed Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-1">
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={speedChartData}>
|
||||
<CartesianGrid stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={axisStyle}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value: number) =>
|
||||
prettyBytes(value, { bits: true })
|
||||
}
|
||||
tick={axisStyle}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) =>
|
||||
prettyBytes(value, { bits: true })
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={axisStyle} iconType="circle" />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Max Speed"
|
||||
stroke="#06b6d4"
|
||||
fill="#06b6d4"
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Average Speed"
|
||||
stroke="#8b5cf6"
|
||||
fill="#8b5cf6"
|
||||
fillOpacity={0.8}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Median Speed"
|
||||
stroke="#f59e0b"
|
||||
fill="#f59e0b"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Min Speed"
|
||||
stroke="#ec4899"
|
||||
fill="#ec4899"
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="px-1">Completed Downloads Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-1">
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={completionChartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis tick={axisStyle} interval="preserveStartEnd" />
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(59, 130, 246, 0.1)" }}
|
||||
contentStyle={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={axisStyle} iconType="rect" />
|
||||
<Bar
|
||||
dataKey="Completed Downloads"
|
||||
fill="#299d90"
|
||||
fillOpacity={0.8}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TelegramStats;
|
||||
|
|
@ -14,6 +14,9 @@ import { useTelegramAccount } from "@/hooks/use-telegram-account";
|
|||
import Proxys from "@/components/proxys";
|
||||
import SettingsForm from "@/components/settings-form";
|
||||
import About from "@/components/about";
|
||||
import { ChartColumnIncreasingIcon } from "@/components/ui/chart-column-increasing";
|
||||
import { LayoutPanelTopIcon } from "@/components/ui/layout-panel-top";
|
||||
import FilePhaseStatistics from "@/components/file-phase-statistics";
|
||||
|
||||
export const SettingsDialog: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
|
@ -53,9 +56,24 @@ export const SettingsDialog: React.FC = () => {
|
|||
<SettingsForm />
|
||||
</TabsContent>
|
||||
<TabsContent value="statistics" className="h-full overflow-hidden">
|
||||
<div className="h-full flex flex-col overflow-y-scroll">
|
||||
<div className="flex h-full flex-col overflow-y-scroll">
|
||||
{accountId ? (
|
||||
<FileStatistics telegramId={accountId} />
|
||||
<Tabs defaultValue="panel">
|
||||
<TabsList className="bg-none!">
|
||||
<TabsTrigger value="panel" className="w-10 h-10 scale-75 data-[state=active]:bg-accent">
|
||||
<LayoutPanelTopIcon />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="phase" className="w-10 h-10 scale-75 data-[state=active]:bg-accent">
|
||||
<ChartColumnIncreasingIcon />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="panel">
|
||||
<FileStatistics telegramId={accountId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="phase">
|
||||
<FilePhaseStatistics telegramId={accountId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
|
|
|
|||
79
web/src/components/ui/chart-column-increasing.tsx
Normal file
79
web/src/components/ui/chart-column-increasing.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useAnimation, type Variants } from "motion/react";
|
||||
|
||||
const frameVariants: Variants = {
|
||||
visible: { opacity: 1 },
|
||||
hidden: { opacity: 1 },
|
||||
};
|
||||
|
||||
const lineVariants: Variants = {
|
||||
visible: { pathLength: 1, opacity: 1 },
|
||||
hidden: { pathLength: 0, opacity: 0 },
|
||||
};
|
||||
|
||||
const ChartColumnIncreasingIcon = () => {
|
||||
const controls = useAnimation();
|
||||
|
||||
const handleHoverStart = async () => {
|
||||
await controls.start((i) => ({
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
transition: { delay: i * 0.1, duration: 0.3 },
|
||||
}));
|
||||
await controls.start((i) => ({
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: { delay: i * 0.1, duration: 0.3 },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleHoverEnd = () => {
|
||||
void controls.start("visible");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center justify-center rounded-md p-2 transition-colors duration-200 hover:bg-accent"
|
||||
onMouseEnter={handleHoverStart}
|
||||
onMouseLeave={handleHoverEnd}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
variants={lineVariants}
|
||||
initial="visible"
|
||||
animate={controls}
|
||||
custom={1}
|
||||
d="M13 17V9"
|
||||
/>
|
||||
<motion.path
|
||||
variants={lineVariants}
|
||||
initial="visible"
|
||||
animate={controls}
|
||||
custom={2}
|
||||
d="M18 17V5"
|
||||
/>
|
||||
<motion.path variants={frameVariants} d="M3 3v16a2 2 0 0 0 2 2h16" />
|
||||
<motion.path
|
||||
variants={lineVariants}
|
||||
initial="visible"
|
||||
animate={controls}
|
||||
custom={0}
|
||||
d="M8 17v-3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChartColumnIncreasingIcon };
|
||||
365
web/src/components/ui/chart.tsx
Normal file
365
web/src/components/ui/chart.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"use client";
|
||||
/* eslint-disable */
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils"; // Format: { THEME_NAME: CSS_SELECTOR }
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item!.dataKey || item!.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
92
web/src/components/ui/layout-panel-top.tsx
Normal file
92
web/src/components/ui/layout-panel-top.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useAnimation } from "motion/react";
|
||||
|
||||
const LayoutPanelTopIcon = () => {
|
||||
const controls = useAnimation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center justify-center rounded-md p-2 transition-colors duration-200 hover:bg-accent"
|
||||
onMouseEnter={() => controls.start("animate")}
|
||||
onMouseLeave={() => controls.start("normal")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.rect
|
||||
width="18"
|
||||
height="7"
|
||||
x="3"
|
||||
y="3"
|
||||
rx="1"
|
||||
initial="normal"
|
||||
animate={controls}
|
||||
variants={{
|
||||
normal: { opacity: 1, translateY: 0 },
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
translateY: [-5, 0],
|
||||
transition: {
|
||||
opacity: { duration: 0.5, times: [0.2, 1] },
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<motion.rect
|
||||
width="7"
|
||||
height="7"
|
||||
x="3"
|
||||
y="14"
|
||||
rx="1"
|
||||
initial="normal"
|
||||
animate={controls}
|
||||
variants={{
|
||||
normal: { opacity: 1, translateX: 0 },
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
translateX: [-10, 0],
|
||||
transition: {
|
||||
opacity: { duration: 0.7, times: [0.5, 1] },
|
||||
translateX: { delay: 0.3 },
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<motion.rect
|
||||
width="7"
|
||||
height="7"
|
||||
x="14"
|
||||
y="14"
|
||||
rx="1"
|
||||
initial="normal"
|
||||
animate={controls}
|
||||
variants={{
|
||||
normal: { opacity: 1, translateX: 0 },
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
translateX: [10, 0],
|
||||
transition: {
|
||||
opacity: { duration: 0.8, times: [0.5, 1] },
|
||||
translateX: { delay: 0.4 },
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LayoutPanelTopIcon };
|
||||
|
|
@ -30,6 +30,7 @@ export function useFiles(accountId: string, chatId: string) {
|
|||
{
|
||||
downloadStatus: DownloadStatus;
|
||||
localPath: string;
|
||||
completionDate: number;
|
||||
}
|
||||
>
|
||||
>({});
|
||||
|
|
@ -74,6 +75,7 @@ export function useFiles(accountId: string, chatId: string) {
|
|||
fileId: number;
|
||||
downloadStatus: DownloadStatus;
|
||||
localPath: string;
|
||||
completionDate: number;
|
||||
};
|
||||
|
||||
setLatestFileStatus((prev) => ({
|
||||
|
|
@ -82,6 +84,7 @@ export function useFiles(accountId: string, chatId: string) {
|
|||
downloadStatus:
|
||||
data.downloadStatus ?? prev[data.fileId]?.downloadStatus,
|
||||
localPath: data.localPath ?? prev[data.fileId]?.localPath,
|
||||
completionDate: data.completionDate ?? prev[data.fileId]?.completionDate,
|
||||
},
|
||||
}));
|
||||
}, [lastJsonMessage]);
|
||||
|
|
@ -96,6 +99,8 @@ export function useFiles(accountId: string, chatId: string) {
|
|||
downloadStatus:
|
||||
latestFilesStatus[file.id]?.downloadStatus ?? file.downloadStatus,
|
||||
localPath: latestFilesStatus[file.id]?.localPath ?? file.localPath,
|
||||
completionDate:
|
||||
latestFilesStatus[file.id]?.completionDate ?? file.completionDate,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export type TelegramFile = {
|
|||
caption: string;
|
||||
localPath: string;
|
||||
hasSensitiveContent: boolean;
|
||||
startDate: number;
|
||||
completionDate: number;
|
||||
};
|
||||
|
||||
export type TDFile = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue