✨ feat: Remove the loading image function and add the function of previewing downloaded images and videos
This commit is contained in:
parent
8f48c7939f
commit
69d57b842d
38 changed files with 2213 additions and 847 deletions
201
api/src/main/java/telegram/files/FileRouteHandler.java
Normal file
201
api/src/main/java/telegram/files/FileRouteHandler.java
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package telegram.files;
|
||||
|
||||
import cn.hutool.log.Log;
|
||||
import cn.hutool.log.LogFactory;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.file.FileProps;
|
||||
import io.vertx.core.file.FileSystem;
|
||||
import io.vertx.core.http.HttpHeaders;
|
||||
import io.vertx.core.http.HttpMethod;
|
||||
import io.vertx.core.http.HttpServerRequest;
|
||||
import io.vertx.core.http.HttpServerResponse;
|
||||
import io.vertx.core.net.impl.URIDecoder;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static io.netty.handler.codec.http.HttpResponseStatus.PARTIAL_CONTENT;
|
||||
import static io.netty.handler.codec.http.HttpResponseStatus.REQUESTED_RANGE_NOT_SATISFIABLE;
|
||||
|
||||
public class FileRouteHandler {
|
||||
private static final Log LOG = LogFactory.get();
|
||||
|
||||
public void handle(RoutingContext context, String path, String mimeType) {
|
||||
HttpServerRequest request = context.request();
|
||||
|
||||
if (request.method() != HttpMethod.GET && request.method() != HttpMethod.HEAD) {
|
||||
if (LOG.isTraceEnabled())
|
||||
LOG.trace("Not GET or HEAD so ignoring request");
|
||||
context.next();
|
||||
} else {
|
||||
if (!request.isEnded()) {
|
||||
request.pause();
|
||||
}
|
||||
// decode URL path
|
||||
String uriDecodedPath = URIDecoder.decodeURIComponent(context.normalizedPath(), false);
|
||||
// if the normalized path is null it cannot be resolved
|
||||
if (uriDecodedPath == null) {
|
||||
LOG.warn("Invalid path: " + context.request().path());
|
||||
context.next();
|
||||
return;
|
||||
}
|
||||
// Access fileSystem once here to be safe
|
||||
FileSystem fs = context.vertx().fileSystem();
|
||||
|
||||
sendStatic(context, fs, path, mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendStatic(RoutingContext context, FileSystem fileSystem, String path, String mimeType) {
|
||||
// verify if the file exists
|
||||
fileSystem
|
||||
.exists(path, exists -> {
|
||||
if (exists.failed()) {
|
||||
if (!context.request().isEnded()) {
|
||||
context.request().resume();
|
||||
}
|
||||
context.fail(exists.cause());
|
||||
return;
|
||||
}
|
||||
|
||||
// file does not exist, continue...
|
||||
if (!exists.result()) {
|
||||
if (!context.request().isEnded()) {
|
||||
context.request().resume();
|
||||
}
|
||||
context.next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to read the props from the filesystem
|
||||
fileSystem.props(path, res -> {
|
||||
if (res.succeeded()) {
|
||||
FileProps props = res.result();
|
||||
if (props == null) {
|
||||
if (!context.request().isEnded()) {
|
||||
context.request().resume();
|
||||
}
|
||||
context.next();
|
||||
} else if (props.isDirectory()) {
|
||||
context.next();
|
||||
} else {
|
||||
sendFile(context, path, mimeType, props);
|
||||
}
|
||||
} else {
|
||||
if (!context.request().isEnded()) {
|
||||
context.request().resume();
|
||||
}
|
||||
context.fail(res.cause());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static final Pattern RANGE = Pattern.compile("^bytes=(\\d+)-(\\d*)$");
|
||||
|
||||
private void sendFile(RoutingContext context, String file, String contentType, FileProps fileProps) {
|
||||
final HttpServerRequest request = context.request();
|
||||
final HttpServerResponse response = context.response();
|
||||
|
||||
Long offset = null;
|
||||
long end;
|
||||
MultiMap headers;
|
||||
|
||||
if (response.closed())
|
||||
return;
|
||||
|
||||
// check if the client is making a range request
|
||||
String range = request.getHeader("Range");
|
||||
// end byte is length - 1
|
||||
end = fileProps.size() - 1;
|
||||
|
||||
if (range != null) {
|
||||
Matcher m = RANGE.matcher(range);
|
||||
if (m.matches()) {
|
||||
try {
|
||||
String part = m.group(1);
|
||||
// offset cannot be empty
|
||||
offset = Long.parseLong(part);
|
||||
// offset must fall inside the limits of the file
|
||||
if (offset < 0 || offset >= fileProps.size()) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
// length can be empty
|
||||
part = m.group(2);
|
||||
if (part != null && !part.isEmpty()) {
|
||||
// ranges are inclusive
|
||||
end = Math.min(end, Long.parseLong(part));
|
||||
// end offset must not be smaller than start offset
|
||||
if (end < offset) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException | IndexOutOfBoundsException e) {
|
||||
context.response().putHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + fileProps.size());
|
||||
if (!context.request().isEnded()) {
|
||||
context.request().resume();
|
||||
}
|
||||
context.fail(REQUESTED_RANGE_NOT_SATISFIABLE.code());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notify client we support range requests
|
||||
headers = response.headers();
|
||||
headers.set(HttpHeaders.ACCEPT_RANGES, "bytes");
|
||||
// send the content length even for HEAD requests
|
||||
headers.set(HttpHeaders.CONTENT_LENGTH, Long.toString(end + 1 - (offset == null ? 0 : offset)));
|
||||
|
||||
if (request.method() == HttpMethod.HEAD) {
|
||||
response.end();
|
||||
} else {
|
||||
if (offset != null) {
|
||||
// must return content range
|
||||
headers.set(HttpHeaders.CONTENT_RANGE, "bytes " + offset + "-" + end + "/" + fileProps.size());
|
||||
// return a partial response
|
||||
response.setStatusCode(PARTIAL_CONTENT.code());
|
||||
|
||||
final long finalOffset = offset;
|
||||
final long finalLength = end + 1 - offset;
|
||||
if (contentType != null) {
|
||||
if (contentType.startsWith("text")) {
|
||||
response.putHeader(HttpHeaders.CONTENT_TYPE, contentType + ";charset=" + Charset.defaultCharset().name());
|
||||
} else {
|
||||
response.putHeader(HttpHeaders.CONTENT_TYPE, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
response.sendFile(file, finalOffset, finalLength, res2 -> {
|
||||
if (res2.failed()) {
|
||||
if (!context.request().isEnded()) {
|
||||
context.request().resume();
|
||||
}
|
||||
context.fail(res2.cause());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// guess content type
|
||||
if (contentType != null) {
|
||||
if (contentType.startsWith("text")) {
|
||||
response.putHeader(HttpHeaders.CONTENT_TYPE, contentType + ";charset=" + Charset.defaultCharset().name());
|
||||
} else {
|
||||
response.putHeader(HttpHeaders.CONTENT_TYPE, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
response.sendFile(file, res2 -> {
|
||||
if (res2.failed()) {
|
||||
if (!context.request().isEnded()) {
|
||||
context.request().resume();
|
||||
}
|
||||
context.fail(res2.cause());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import io.vertx.core.http.CookieSameSite;
|
|||
import io.vertx.core.http.HttpMethod;
|
||||
import io.vertx.core.http.HttpServerOptions;
|
||||
import io.vertx.core.http.HttpServerResponse;
|
||||
import io.vertx.core.impl.NoStackTraceException;
|
||||
import io.vertx.core.json.Json;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
|
@ -51,6 +52,8 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
|
||||
private final AutoRecordsHolder autoRecordsHolder = new AutoRecordsHolder();
|
||||
|
||||
private final FileRouteHandler fileRouteHandler = new FileRouteHandler();
|
||||
|
||||
private static final String SESSION_COOKIE_NAME = "tf";
|
||||
|
||||
@Override
|
||||
|
|
@ -150,13 +153,13 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
router.get("/telegram/:telegramId/ping").handler(this::handleTelegramPing);
|
||||
router.get("/telegram/:telegramId/test-network").handler(this::handleTelegramTestNetwork);
|
||||
|
||||
router.get("/file/preview").handler(this::handleFilePreview);
|
||||
router.post("/file/start-download").handler(this::handleFileStartDownload);
|
||||
router.post("/file/start-download-multiple").handler(this::handleFileStartDownloadMultiple);
|
||||
router.post("/file/cancel-download").handler(this::handleFileCancelDownload);
|
||||
router.post("/file/toggle-pause-download").handler(this::handleFileTogglePauseDownload);
|
||||
router.post("/file/remove").handler(this::handleFileRemove);
|
||||
router.post("/file/auto-download").handler(this::handleAutoDownload);
|
||||
router.get("/:telegramId/file/:uniqueId").handler(this::handleFilePreview);
|
||||
router.post("/:telegramId/file/start-download").handler(this::handleFileStartDownload);
|
||||
router.post("/:telegramId/file/start-download-multiple").handler(this::handleFileStartDownloadMultiple);
|
||||
router.post("/:telegramId/file/cancel-download").handler(this::handleFileCancelDownload);
|
||||
router.post("/:telegramId/file/toggle-pause-download").handler(this::handleFileTogglePauseDownload);
|
||||
router.post("/:telegramId/file/remove").handler(this::handleFileRemove);
|
||||
router.post("/:telegramId/file/auto-download").handler(this::handleAutoDownload);
|
||||
|
||||
router.route()
|
||||
.failureHandler(ctx -> {
|
||||
|
|
@ -557,41 +560,32 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
}
|
||||
|
||||
private void handleFilePreview(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
Optional<TelegramVerticle> telegramVerticleOptional = getTelegramVerticle(ctx.pathParam("telegramId"));
|
||||
if (telegramVerticleOptional.isEmpty()) {
|
||||
ctx.fail(404);
|
||||
return;
|
||||
}
|
||||
String chatId = ctx.request().getParam("chatId");
|
||||
String messageId = ctx.request().getParam("messageId");
|
||||
if (StrUtil.isBlank(chatId) || StrUtil.isBlank(messageId)) {
|
||||
ctx.fail(400);
|
||||
TelegramVerticle telegramVerticle = telegramVerticleOptional.get();
|
||||
String uniqueId = ctx.pathParam("uniqueId");
|
||||
if (StrUtil.isBlank(uniqueId)) {
|
||||
ctx.fail(404);
|
||||
return;
|
||||
}
|
||||
|
||||
telegramVerticle.loadPreview(Convert.toLong(chatId), Convert.toLong(messageId))
|
||||
.onSuccess(fileIdOrPath -> {
|
||||
if (fileIdOrPath == null) {
|
||||
ctx.end();
|
||||
return;
|
||||
telegramVerticle.loadPreview(uniqueId)
|
||||
.onSuccess(tuple -> {
|
||||
String mimeType = tuple.v2;
|
||||
if (StrUtil.isBlank(mimeType)) {
|
||||
mimeType = FileUtil.getMimeType(tuple.v1);
|
||||
}
|
||||
if (fileIdOrPath instanceof Integer fileId) {
|
||||
ctx.json(JsonObject.of("fileId", fileId));
|
||||
return;
|
||||
}
|
||||
String path = (String) fileIdOrPath;
|
||||
|
||||
ctx.response()
|
||||
.putHeader("Content-Type", FileUtil.getMimeType(path))
|
||||
.end(Buffer.buffer(FileUtil.readBytes(path)));
|
||||
fileRouteHandler.handle(ctx, tuple.v1, mimeType);
|
||||
})
|
||||
.onFailure(ctx::fail);
|
||||
}
|
||||
|
||||
private void handleFileStartDownload(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId"));
|
||||
|
||||
JsonObject jsonObject = ctx.body().asJsonObject();
|
||||
Long chatId = jsonObject.getLong("chatId");
|
||||
|
|
@ -608,10 +602,7 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
}
|
||||
|
||||
private void handleFileStartDownloadMultiple(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId"));
|
||||
|
||||
JsonObject jsonObject = ctx.body().asJsonObject();
|
||||
Long chatId = jsonObject.getLong("chatId");
|
||||
|
|
@ -638,10 +629,7 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
}
|
||||
|
||||
private void handleFileCancelDownload(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId"));
|
||||
|
||||
JsonObject jsonObject = ctx.body().asJsonObject();
|
||||
Integer fileId = jsonObject.getInteger("fileId");
|
||||
|
|
@ -656,10 +644,7 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
}
|
||||
|
||||
private void handleFileTogglePauseDownload(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId"));
|
||||
|
||||
JsonObject jsonObject = ctx.body().asJsonObject();
|
||||
Integer fileId = jsonObject.getInteger("fileId");
|
||||
|
|
@ -675,7 +660,7 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
}
|
||||
|
||||
private void handleFileRemove(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId"));
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -693,10 +678,8 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
}
|
||||
|
||||
private void handleAutoDownload(RoutingContext ctx) {
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticle(ctx);
|
||||
if (telegramVerticle == null) {
|
||||
return;
|
||||
}
|
||||
TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId"));
|
||||
|
||||
String chatId = ctx.request().getParam("chatId");
|
||||
if (StrUtil.isBlank(chatId)) {
|
||||
ctx.fail(400);
|
||||
|
|
@ -715,6 +698,14 @@ public class HttpVerticle extends AbstractVerticle {
|
|||
.findFirst();
|
||||
}
|
||||
|
||||
public static TelegramVerticle getTelegramVerticleThrow(String telegramId) {
|
||||
Object id = NumberUtil.isNumber(telegramId) ? Convert.toLong(telegramId) : telegramId;
|
||||
return telegramVerticles.stream()
|
||||
.filter(t -> Objects.equals(t.getId(), id))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new NoStackTraceException("Telegram account not found!"));
|
||||
}
|
||||
|
||||
public static Optional<TelegramVerticle> getTelegramVerticle(long telegramId) {
|
||||
return telegramVerticles.stream()
|
||||
.filter(t -> t.telegramRecord != null && t.telegramRecord.id() == telegramId)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import cn.hutool.core.convert.TypeConverter;
|
|||
import cn.hutool.core.util.ClassUtil;
|
||||
import cn.hutool.core.util.ReflectUtil;
|
||||
import io.vertx.core.impl.NoStackTraceException;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.drinkless.tdlib.TdApi;
|
||||
import org.jooq.lambda.tuple.Tuple;
|
||||
import org.jooq.lambda.tuple.Tuple1;
|
||||
|
|
@ -192,9 +193,7 @@ public class TdApiHelp {
|
|||
case TdApi.MessageDocument.CONSTRUCTOR -> {
|
||||
return Optional.of((T) new DocumentHandler(message));
|
||||
}
|
||||
default -> {
|
||||
return Optional.empty();
|
||||
}
|
||||
default -> throw new NoStackTraceException("Unsupported message type: " + message.content.getConstructor());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +222,10 @@ public class TdApiHelp {
|
|||
}
|
||||
|
||||
public abstract TdApi.File getFile();
|
||||
|
||||
public JsonObject getExtraInfo() {
|
||||
return JsonObject.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static class PhotoHandler extends FileHandler<TdApi.MessagePhoto> {
|
||||
|
|
@ -291,6 +294,14 @@ public class TdApiHelp {
|
|||
public TdApi.File getFile() {
|
||||
return content.photo.sizes[content.photo.sizes.length - 1].photo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonObject getExtraInfo() {
|
||||
TdApi.PhotoSize photo = content.photo.sizes[content.photo.sizes.length - 1];
|
||||
return JsonObject.of("width", photo.width,
|
||||
"height", photo.height,
|
||||
"type", photo.type);
|
||||
}
|
||||
}
|
||||
|
||||
public static class VideoHandler extends FileHandler<TdApi.MessageVideo> {
|
||||
|
|
@ -352,6 +363,15 @@ public class TdApiHelp {
|
|||
public TdApi.File getFile() {
|
||||
return content.video.video;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonObject getExtraInfo() {
|
||||
TdApi.Video video = content.video;
|
||||
return JsonObject.of("width", video.width,
|
||||
"height", video.height,
|
||||
"duration", video.duration,
|
||||
"mimeType", video.mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
public static class AudioHandler extends FileHandler<TdApi.MessageAudio> {
|
||||
|
|
|
|||
|
|
@ -185,17 +185,10 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
Map<Long, TdApi.Message> messageMap = Arrays.stream(m.messages)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toMap(message -> message.id, Function.identity()));
|
||||
List<FileRecord> fileRecords = r.v1.stream()
|
||||
.map(fileRecord -> {
|
||||
TdApi.Message message = messageMap.get(fileRecord.messageId());
|
||||
FileRecord source = TdApiHelp.getFileHandler(message)
|
||||
.map(fileHandler -> fileHandler.convertFileRecord(telegramRecord.id()))
|
||||
.orElse(null);
|
||||
if (source != null) {
|
||||
fileRecord = fileRecord.withSourceField(source.id(), source.downloadedSize());
|
||||
}
|
||||
return fileRecord;
|
||||
})
|
||||
List<JsonObject> fileRecords = r.v1.stream()
|
||||
.map(fileRecord ->
|
||||
this.withSource(fileRecord, messageMap.get(fileRecord.messageId())))
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return Tuple.tuple(fileRecords, r.v2, r.v3);
|
||||
});
|
||||
|
|
@ -280,66 +273,18 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
});
|
||||
}
|
||||
|
||||
public Future<Object> loadPreview(long chatId, long messageId) {
|
||||
return DataVerticle.settingRepository
|
||||
.getByKey(SettingKey.needToLoadImages)
|
||||
.compose(needToLoadImages -> {
|
||||
if (!(boolean) needToLoadImages) {
|
||||
return Future.failedFuture("Need to load images is disabled");
|
||||
public Future<Tuple2<String, String>> loadPreview(String uniqueId) {
|
||||
return DataVerticle.fileRepository
|
||||
.getByUniqueId(uniqueId)
|
||||
.compose(fileRecord -> {
|
||||
if (fileRecord == null || !fileRecord.isDownloadStatus(FileRecord.DownloadStatus.completed)
|
||||
|| !FileUtil.exist(fileRecord.localPath())) {
|
||||
return Future.failedFuture("File not found or not downloaded");
|
||||
}
|
||||
return client.execute(new TdApi.GetMessage(chatId, messageId));
|
||||
})
|
||||
.compose(message -> {
|
||||
TdApiHelp.FileHandler<? extends TdApi.MessageContent> fileHandler = TdApiHelp.getFileHandler(message)
|
||||
.orElseThrow(() -> new NoStackTraceException("not support message type"));
|
||||
|
||||
return DataVerticle.settingRepository.getByKey(SettingKey.imageLoadSize)
|
||||
.map(size -> Tuple.tuple(message, fileHandler.getPreviewFileId(Tuple.tuple(size))));
|
||||
})
|
||||
.compose(tuple -> {
|
||||
TdApi.Message message = tuple.v1;
|
||||
TdApi.File file = tuple.v2;
|
||||
if (file.local != null
|
||||
&& file.local.isDownloadingCompleted
|
||||
&& FileUtil.exist(file.local.path)) {
|
||||
return Future.succeededFuture(file.local.path);
|
||||
}
|
||||
|
||||
return savePreviewFile(message, file)
|
||||
.compose(r -> {
|
||||
TdApi.DownloadFile downloadFile = new TdApi.DownloadFile();
|
||||
downloadFile.fileId = file.id;
|
||||
downloadFile.priority = 32;
|
||||
downloadFile.synchronous = true;
|
||||
|
||||
return client.execute(downloadFile)
|
||||
.map(file.id);
|
||||
});
|
||||
return Future.succeededFuture(Tuple.tuple(fileRecord.localPath(), fileRecord.mimeType()));
|
||||
});
|
||||
}
|
||||
|
||||
private Future<Void> savePreviewFile(TdApi.Message message, TdApi.File file) {
|
||||
return Future.future(promise -> {
|
||||
if (TdApiHelp.getFileId(message) != file.id) {
|
||||
promise.complete();
|
||||
} else {
|
||||
DataVerticle.fileRepository.getByUniqueId(file.remote.uniqueId)
|
||||
.onSuccess(fileRecord -> {
|
||||
if (fileRecord != null) {
|
||||
promise.complete();
|
||||
} else {
|
||||
DataVerticle.fileRepository.create(TdApiHelp.getFileHandler(message)
|
||||
.orElseThrow()
|
||||
.convertFileRecord(telegramRecord.id()))
|
||||
.onSuccess(r -> promise.complete())
|
||||
.onFailure(promise::fail);
|
||||
}
|
||||
})
|
||||
.onFailure(promise::fail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Future<TdApi.File> startDownload(Long chatId, Long messageId, Integer fileId) {
|
||||
return Future.all(
|
||||
client.execute(new TdApi.GetFile(fileId)),
|
||||
|
|
@ -898,24 +843,9 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
List<JsonObject> fileObjects = messages.stream()
|
||||
.filter(message -> TdApiHelp.FILE_CONTENT_CONSTRUCTORS.contains(message.content.getConstructor()))
|
||||
.map(message -> {
|
||||
FileRecord source = TdApiHelp.getFileHandler(message)
|
||||
.map(fileHandler -> fileHandler.convertFileRecord(telegramRecord.id()))
|
||||
.orElse(null);
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
FileRecord fileRecord = fileRecords.get(TdApiHelp.getFileUniqueId(message));
|
||||
if (fileRecord == null) {
|
||||
fileRecord = source;
|
||||
} else {
|
||||
fileRecord = fileRecord.withSourceField(source.id(), source.downloadedSize());
|
||||
}
|
||||
|
||||
//TODO Processing of the same file under different accounts
|
||||
|
||||
JsonObject fileObject = JsonObject.mapFrom(fileRecord);
|
||||
fileObject.put("formatDate", DateUtil.date(fileObject.getLong("date") * 1000).toString());
|
||||
return fileObject;
|
||||
return this.withSource(fileRecords.get(TdApiHelp.getFileUniqueId(message)), message);
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
|
@ -927,6 +857,26 @@ public class TelegramVerticle extends AbstractVerticle {
|
|||
});
|
||||
}
|
||||
|
||||
private JsonObject withSource(FileRecord fileRecord, TdApi.Message message) {
|
||||
TdApiHelp.FileHandler<? extends TdApi.MessageContent> fileHandler = TdApiHelp.getFileHandler(message)
|
||||
.orElse(null);
|
||||
if (fileHandler == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
FileRecord source = fileHandler.convertFileRecord(telegramRecord.id());
|
||||
if (fileRecord == null) {
|
||||
fileRecord = source;
|
||||
} else {
|
||||
fileRecord = fileRecord.withSourceField(source.id(), source.downloadedSize());
|
||||
}
|
||||
|
||||
JsonObject fileObject = JsonObject.mapFrom(fileRecord);
|
||||
fileObject.put("formatDate", DateUtil.date(fileObject.getLong("date") * 1000).toString());
|
||||
fileObject.put("extra", fileHandler.getExtraInfo());
|
||||
return fileObject;
|
||||
}
|
||||
|
||||
private List<JsonObject> convertRangedSpeedStats(List<StatisticRecord> statisticRecords, int timeRange) {
|
||||
TreeMap<String, List<JsonObject>> groupedSpeedStats = new TreeMap<>(Comparator.comparing(
|
||||
switch (timeRange) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import java.util.function.Function;
|
|||
public enum SettingKey {
|
||||
version(Version::new),
|
||||
uniqueOnly(Convert::toBool, false),
|
||||
needToLoadImages(Convert::toBool, false),
|
||||
imageLoadSize,
|
||||
alwaysHide(Convert::toBool, false),
|
||||
showSensitiveContent(Convert::toBool, false),
|
||||
|
|
|
|||
202
web/package-lock.json
generated
202
web/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "telegram-files-web",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "telegram-files-web",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.2.0",
|
||||
"@dnd-kit/modifiers": "^8.0.0",
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
|
|
@ -38,6 +39,7 @@
|
|||
"framer-motion": "^11.15.0",
|
||||
"geist": "^1.3.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"motion": "^11.15.0",
|
||||
|
|
@ -60,6 +62,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.56.10",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
|
@ -2310,6 +2313,127 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz",
|
||||
"integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.0",
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||
"@radix-ui/react-use-previous": "1.1.0",
|
||||
"@radix-ui/react-use-size": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
|
||||
"integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-slot": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
|
||||
|
|
@ -2897,6 +3021,13 @@
|
|||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.17.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz",
|
||||
|
|
@ -6169,7 +6300,8 @@
|
|||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
|
|
@ -9641,6 +9773,64 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-slider": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz",
|
||||
"integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==",
|
||||
"requires": {
|
||||
"@radix-ui/number": "1.1.0",
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||
"@radix-ui/react-use-previous": "1.1.0",
|
||||
"@radix-ui/react-use-size": "1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
|
||||
},
|
||||
"@radix-ui/react-collection": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
|
||||
"integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==",
|
||||
"requires": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-slot": "1.1.2"
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"requires": {}
|
||||
},
|
||||
"@radix-ui/react-primitive": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||
"requires": {
|
||||
"@radix-ui/react-slot": "1.1.2"
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"requires": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
|
||||
|
|
@ -9981,6 +10171,12 @@
|
|||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "20.17.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
|
|
@ -46,6 +47,7 @@
|
|||
"framer-motion": "^11.15.0",
|
||||
"geist": "^1.3.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"motion": "^11.15.0",
|
||||
|
|
@ -68,6 +70,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.56.10",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Header } from "@/components/header";
|
||||
import { FileList } from "@/components/file-list";
|
||||
import Files from "@/components/files";
|
||||
|
||||
export default async function ChatFilesPage({
|
||||
params,
|
||||
|
|
@ -11,7 +11,7 @@ export default async function ChatFilesPage({
|
|||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Header />
|
||||
<FileList accountId={accountId} chatId={chatId} />
|
||||
<Files accountId={accountId} chatId={chatId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export default function AutoDownloadDialog() {
|
|||
const { trigger: triggerAuto, isMutating: isAutoMutating } = useSWRMutation(
|
||||
!accountId || !chat
|
||||
? undefined
|
||||
: `/file/auto-download?telegramId=${accountId}&chatId=${chat?.id}`,
|
||||
: `/${accountId}/file/auto-download?telegramId=${accountId}&chatId=${chat?.id}`,
|
||||
(key, { arg }: { arg: { rule: AutoDownloadRule } }) => {
|
||||
return POST(key, arg);
|
||||
},
|
||||
|
|
|
|||
52
web/src/components/file-avatar.tsx
Normal file
52
web/src/components/file-avatar.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { TelegramFile } from "@/lib/types";
|
||||
import { FileAudio2Icon, FileIcon, ImageIcon, VideoIcon } from "lucide-react";
|
||||
import SpoiledWrapper from "@/components/spoiled-wrapper";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function FileAvatar({
|
||||
file,
|
||||
className,
|
||||
}: {
|
||||
file: TelegramFile;
|
||||
className?: string;
|
||||
}) {
|
||||
const getIcon = () => {
|
||||
switch (file.type) {
|
||||
case "photo":
|
||||
return <ImageIcon className="h-6 w-6" />;
|
||||
case "video":
|
||||
return <VideoIcon className="h-6 w-6" />;
|
||||
case "audio":
|
||||
return <FileAudio2Icon className="h-6 w-6" />;
|
||||
default:
|
||||
return <FileIcon className="h-6 w-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.thumbnail ? (
|
||||
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
|
||||
<Image
|
||||
src={`data:image/jpeg;base64,${file.thumbnail}`}
|
||||
alt={file.fileName ?? "File thumbnail"}
|
||||
width={32}
|
||||
height={32}
|
||||
className={cn(className, "rounded object-cover")}
|
||||
/>
|
||||
</SpoiledWrapper>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-16 w-16 items-center justify-center rounded bg-muted",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{getIcon()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import {
|
||||
Calendar,
|
||||
Clock10,
|
||||
ClockArrowDown,
|
||||
Download,
|
||||
FileAudio2Icon,
|
||||
FileIcon,
|
||||
HardDrive,
|
||||
ImageIcon,
|
||||
Type,
|
||||
VideoIcon,
|
||||
} from "lucide-react";
|
||||
import SpoiledWrapper from "@/components/spoiled-wrapper";
|
||||
import Image from "next/image";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import FileProgress from "@/components/file-progress";
|
||||
import FileControl, { MobileFileControl } from "@/components/file-control";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
import PhotoPreview from "@/components/photo-preview";
|
||||
import React from "react";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import FileStatus from "@/components/file-status";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileSpeed } from "@/hooks/use-file-speed";
|
||||
|
||||
interface FileCardProps {
|
||||
file: TelegramFile;
|
||||
}
|
||||
|
||||
export function FileCard({ file }: FileCardProps) {
|
||||
const { downloadProgress, downloadSpeed } = useFileSpeed(file.id);
|
||||
return (
|
||||
<FileDrawer file={file} downloadProgress={downloadProgress}>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileAvatar file={file} />
|
||||
<div className="flex-1">
|
||||
<h3 className="max-w-40 truncate font-medium">{file.fileName}</h3>
|
||||
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{prettyBytes(file.size)} • {file.type}
|
||||
</span>
|
||||
<FileStatus file={file} />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 w-full overflow-hidden",
|
||||
file.downloadStatus === "idle" && "hidden",
|
||||
)}
|
||||
>
|
||||
<FileProgress file={file} downloadProgress={downloadProgress} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<FileControl
|
||||
file={file}
|
||||
downloadSpeed={downloadSpeed}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FileDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function FileAvatar({
|
||||
file,
|
||||
loadPreview,
|
||||
}: {
|
||||
file: TelegramFile;
|
||||
loadPreview?: boolean;
|
||||
}) {
|
||||
const getIcon = () => {
|
||||
switch (file.type) {
|
||||
case "photo":
|
||||
return <ImageIcon className="h-6 w-6" />;
|
||||
case "video":
|
||||
return <VideoIcon className="h-6 w-6" />;
|
||||
case "audio":
|
||||
return <FileAudio2Icon className="h-6 w-6" />;
|
||||
default:
|
||||
return <FileIcon className="h-6 w-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.type === "photo" || file.type === "video" ? (
|
||||
file.thumbnail ? (
|
||||
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
|
||||
{loadPreview ? (
|
||||
<PhotoPreview
|
||||
thumbnail={file.thumbnail}
|
||||
name={file.fileName}
|
||||
chatId={file.chatId}
|
||||
messageId={file.messageId}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={`data:image/jpeg;base64,${file.thumbnail}`}
|
||||
alt={file.fileName ?? "File thumbnail"}
|
||||
width={32}
|
||||
height={32}
|
||||
className="inline-block h-16 w-16 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
</SpoiledWrapper>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded bg-muted">
|
||||
{getIcon()}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded bg-muted">
|
||||
{getIcon()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDrawer({
|
||||
file,
|
||||
downloadProgress,
|
||||
children,
|
||||
}: {
|
||||
file: TelegramFile;
|
||||
downloadProgress: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent
|
||||
className="max-h-[96vh] focus:outline-none"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DrawerHeader className="text-center">
|
||||
<DrawerTitle className="flex flex-col items-center justify-center gap-2">
|
||||
<FileAvatar file={file} loadPreview={true} />
|
||||
<span className="max-w-64 truncate">{file.fileName}</span>
|
||||
</DrawerTitle>
|
||||
<div className="py-1">
|
||||
<FileStatus file={file} />
|
||||
</div>
|
||||
{file.caption && (
|
||||
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
|
||||
<DrawerDescription
|
||||
className="mx-auto mt-2 max-h-48 max-w-md overflow-y-auto text-start"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: file.caption.replaceAll("\n", "<br />"),
|
||||
}}
|
||||
></DrawerDescription>
|
||||
</SpoiledWrapper>
|
||||
)}
|
||||
<FileProgress file={file} downloadProgress={downloadProgress} />
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="px-2">
|
||||
<Separator className="my-2" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Type className="h-4 w-4" />
|
||||
<span>Type</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{file.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span>Size</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{prettyBytes(file.size)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Received At</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatDistanceToNow(new Date(file.date * 1000), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>Downloaded Size</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{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>
|
||||
|
||||
<DrawerFooter>
|
||||
<MobileFileControl file={file} />
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import { type DownloadStatus, type TelegramFile } from "@/lib/types";
|
||||
import { useFileControl } from "@/hooks/use-file-control";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {ArrowDown, FileX, Loader2, Pause, SquareX, StepForward} from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
FileX,
|
||||
Loader2,
|
||||
Pause,
|
||||
SquareX,
|
||||
StepForward,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -17,6 +24,7 @@ interface ActionButtonProps {
|
|||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
loading: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const ActionButton = ({
|
||||
|
|
@ -24,10 +32,15 @@ const ActionButton = ({
|
|||
icon,
|
||||
onClick,
|
||||
loading,
|
||||
isMobile,
|
||||
}: ActionButtonProps) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="xs" onClick={onClick}>
|
||||
<Button
|
||||
variant={isMobile ? "default" : "ghost"}
|
||||
size={isMobile ? "icon" : "xs"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -44,23 +57,32 @@ export default function FileControl({
|
|||
isMobile,
|
||||
}: {
|
||||
file: TelegramFile;
|
||||
downloadSpeed: number;
|
||||
downloadSpeed?: number;
|
||||
hovered?: boolean;
|
||||
isMobile?: boolean;
|
||||
}) {
|
||||
const showDownloadInfo =
|
||||
!hovered &&
|
||||
(file.downloadStatus === "downloading" || file.downloadStatus === "paused");
|
||||
const iconSize = isMobile ? "!h-3 !w-3" : "h-4 w-4";
|
||||
|
||||
const { start, starting, togglePause, togglingPause, cancel, cancelling, remove, removing } =
|
||||
useFileControl(file);
|
||||
const {
|
||||
start,
|
||||
starting,
|
||||
togglePause,
|
||||
togglingPause,
|
||||
cancel,
|
||||
cancelling,
|
||||
remove,
|
||||
removing,
|
||||
} = useFileControl(file);
|
||||
|
||||
const statusMapping: Record<DownloadStatus, ActionButtonProps[]> = {
|
||||
idle: [
|
||||
{
|
||||
onClick: () => start(file.id),
|
||||
tooltipText: "Start Download",
|
||||
icon: <ArrowDown className="h-4 w-4" />,
|
||||
icon: <ArrowDown className={iconSize} />,
|
||||
loading: starting,
|
||||
},
|
||||
],
|
||||
|
|
@ -68,7 +90,7 @@ export default function FileControl({
|
|||
{
|
||||
onClick: () => start(file.id),
|
||||
tooltipText: "Retry",
|
||||
icon: <ArrowDown className="h-4 w-4" />,
|
||||
icon: <ArrowDown className={iconSize} />,
|
||||
loading: starting,
|
||||
},
|
||||
],
|
||||
|
|
@ -76,13 +98,13 @@ export default function FileControl({
|
|||
{
|
||||
onClick: () => togglePause(file.id),
|
||||
tooltipText: "Pause",
|
||||
icon: <Pause className="h-4 w-4" />,
|
||||
icon: <Pause className={iconSize} />,
|
||||
loading: togglingPause,
|
||||
},
|
||||
{
|
||||
onClick: () => cancel(file.id),
|
||||
tooltipText: "Cancel",
|
||||
icon: <SquareX className="h-4 w-4" />,
|
||||
icon: <SquareX className={iconSize} />,
|
||||
loading: cancelling,
|
||||
},
|
||||
],
|
||||
|
|
@ -90,13 +112,13 @@ export default function FileControl({
|
|||
{
|
||||
onClick: () => togglePause(file.id),
|
||||
tooltipText: "Resume",
|
||||
icon: <StepForward className="h-4 w-4" />,
|
||||
icon: <StepForward className={iconSize} />,
|
||||
loading: togglingPause,
|
||||
},
|
||||
{
|
||||
onClick: () => cancel(file.id),
|
||||
tooltipText: "Cancel",
|
||||
icon: <SquareX className="h-4 w-4" />,
|
||||
icon: <SquareX className={iconSize} />,
|
||||
loading: cancelling,
|
||||
},
|
||||
],
|
||||
|
|
@ -104,7 +126,7 @@ export default function FileControl({
|
|||
{
|
||||
onClick: () => remove(file.id),
|
||||
tooltipText: "Remove",
|
||||
icon: <FileX className="h-4 w-4" />,
|
||||
icon: <FileX className={iconSize} />,
|
||||
loading: removing,
|
||||
},
|
||||
],
|
||||
|
|
@ -113,11 +135,11 @@ export default function FileControl({
|
|||
const actionButtons = (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="flex w-full items-center justify-end space-x-2 md:justify-around"
|
||||
className="flex w-full items-center justify-end space-x-4 md:justify-around md:space-x-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
{statusMapping[file.downloadStatus].map((btnProps, index) => (
|
||||
<ActionButton key={index} {...btnProps} />
|
||||
<ActionButton key={index} isMobile={isMobile} {...btnProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -141,7 +163,7 @@ export default function FileControl({
|
|||
className="absolute w-full"
|
||||
>
|
||||
<span className="text-nowrap text-xs">
|
||||
{file.downloadStatus === "downloading"
|
||||
{file.downloadStatus === "downloading" && downloadSpeed
|
||||
? `${prettyBytes(downloadSpeed, { bits: true })}/s`
|
||||
: prettyBytes(file.downloadedSize)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { type RowHeight } from "@/components/table-row-height-switch";
|
|||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useIsMobile from "@/hooks/use-is-mobile";
|
||||
|
||||
interface FileExtraProps {
|
||||
file: TelegramFile;
|
||||
|
|
@ -76,6 +77,7 @@ function FileCaption({ file, rowHeight }: FileExtraProps) {
|
|||
|
||||
function FilePath({ file }: { file: TelegramFile }) {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!file.localPath) {
|
||||
return null;
|
||||
|
|
@ -86,8 +88,11 @@ function FilePath({ file }: { file: TelegramFile }) {
|
|||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileCheck className="h-4 w-4 flex-shrink-0" />
|
||||
<p
|
||||
className="group line-clamp-1 cursor-pointer overflow-hidden truncate text-ellipsis text-wrap rounded px-1 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={() => copyToClipboard(file.localPath)}
|
||||
className={cn(
|
||||
"group line-clamp-1 cursor-pointer overflow-hidden truncate text-ellipsis text-wrap rounded px-1 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
isMobile && "px-0",
|
||||
)}
|
||||
onClick={() => !isMobile && copyToClipboard(file.localPath)}
|
||||
>
|
||||
{file.localPath.split("/").pop()}
|
||||
<Copy className="ml-1 inline-flex h-4 w-4 opacity-0 group-hover:opacity-100" />
|
||||
|
|
|
|||
|
|
@ -26,16 +26,17 @@ import { Button } from "./ui/button";
|
|||
import { DOWNLOAD_STATUS, TRANSFER_STATUS } from "@/components/file-status";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useIsMobile from "@/hooks/use-is-mobile";
|
||||
|
||||
interface FileFiltersProps {
|
||||
telegramId: string;
|
||||
chatId: string;
|
||||
filters: FileFilter;
|
||||
onFiltersChange: (filters: FileFilter) => void;
|
||||
columns: Column[];
|
||||
onColumnConfigChange: (config: Column[]) => void;
|
||||
rowHeight: RowHeight;
|
||||
setRowHeight: (e: RowHeight) => void;
|
||||
columns?: Column[];
|
||||
onColumnConfigChange?: (config: Column[]) => void;
|
||||
rowHeight?: RowHeight;
|
||||
setRowHeight?: (e: RowHeight) => void;
|
||||
}
|
||||
|
||||
const TableRowHeightSwitch = dynamic(
|
||||
|
|
@ -62,11 +63,12 @@ export function FileFilters({
|
|||
chatId,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
columns,
|
||||
onColumnConfigChange,
|
||||
rowHeight,
|
||||
setRowHeight,
|
||||
columns = [],
|
||||
onColumnConfigChange = () => void 0,
|
||||
rowHeight = "m",
|
||||
setRowHeight = () => void 0,
|
||||
}: FileFiltersProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [search, setSearch] = useState(filters.search);
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((search: string) => {
|
||||
|
|
@ -97,7 +99,6 @@ export function FileFilters({
|
|||
|
||||
const statusDisplayValue = useMemo(() => {
|
||||
if (!filters.downloadStatus && !filters.transferStatus) {
|
||||
console.log("no status filter");
|
||||
return "Filter Status";
|
||||
}
|
||||
const downloadStatus =
|
||||
|
|
@ -174,7 +175,8 @@ export function FileFilters({
|
|||
key={`${groupKey}||${statusKey}`}
|
||||
value={`${groupKey}||${statusKey}`}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
!isMobile && "focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{filters[groupKey as keyof FileFilter] === statusKey && (
|
||||
|
|
@ -196,16 +198,18 @@ export function FileFilters({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="hidden gap-4 md:flex">
|
||||
<TableColumnFilter
|
||||
columns={columns}
|
||||
onColumnConfigChange={onColumnConfigChange}
|
||||
/>
|
||||
<TableRowHeightSwitch
|
||||
rowHeight={rowHeight}
|
||||
setRowHeightAction={setRowHeight}
|
||||
/>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="hidden gap-4 md:flex">
|
||||
<TableColumnFilter
|
||||
columns={columns}
|
||||
onColumnConfigChange={onColumnConfigChange}
|
||||
/>
|
||||
<TableRowHeightSwitch
|
||||
rowHeight={rowHeight}
|
||||
setRowHeightAction={setRowHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { Progress } from "@/components/ui/progress";
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function FileProgress({
|
||||
file,
|
||||
downloadProgress,
|
||||
}: {
|
||||
file: TelegramFile;
|
||||
downloadProgress: number;
|
||||
}) {
|
||||
const progress = useMemo(() => {
|
||||
const fileDownloadProgress =
|
||||
file.size > 0
|
||||
? Math.min((file.downloadedSize / file.size) * 100, 100)
|
||||
: 0;
|
||||
if (file.downloadStatus === "downloading") {
|
||||
return downloadProgress > 0 ? downloadProgress : fileDownloadProgress;
|
||||
}
|
||||
if (file.downloadStatus === "paused") {
|
||||
return fileDownloadProgress;
|
||||
}
|
||||
return file.downloadStatus === "completed" ? 100 : 0;
|
||||
}, [file.downloadStatus, file.downloadedSize, file.size, downloadProgress]);
|
||||
|
||||
if (
|
||||
file.downloadStatus === "idle" ||
|
||||
file.downloadStatus === "completed" ||
|
||||
file.size === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<Progress value={progress} className="flex-1 rounded-none md:w-32" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +1,18 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { type RowHeight } from "@/components/table-row-height-switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import FileProgress from "@/components/file-progress";
|
||||
import React, { memo, type ReactNode, useState } from "react";
|
||||
import React, { type ReactNode, useState } from "react";
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import SpoiledWrapper from "@/components/spoiled-wrapper";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import PhotoPreview from "@/components/photo-preview";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import FileStatus from "@/components/file-status";
|
||||
import FileExtra from "@/components/file-extra";
|
||||
import FileControl from "@/components/file-control";
|
||||
import { FileAudioIcon, FileIcon, ImageIcon, VideoIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type Column } from "./table-column-filter";
|
||||
import { useFileSpeed } from "@/hooks/use-file-speed";
|
||||
import FileAvatar from "@/components/file-avatar";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface FileRowProps {
|
||||
type FileRowProps = {
|
||||
index: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
|
@ -37,7 +28,8 @@ interface FileRowProps {
|
|||
columns: Column[];
|
||||
};
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}
|
||||
onFileClick: () => void;
|
||||
};
|
||||
|
||||
export default function FileRow({
|
||||
index,
|
||||
|
|
@ -48,70 +40,19 @@ export default function FileRow({
|
|||
checked,
|
||||
properties,
|
||||
onCheckedChange,
|
||||
onFileClick,
|
||||
}: FileRowProps) {
|
||||
const { rowHeight, dynamicClass, columns } = properties;
|
||||
const { downloadProgress, downloadSpeed } = useFileSpeed(file.id);
|
||||
const { downloadProgress, downloadSpeed } = useFileSpeed(file);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const getFileIcon = (type: TelegramFile["type"]) => {
|
||||
let icon;
|
||||
switch (type) {
|
||||
case "photo":
|
||||
icon = <ImageIcon className="h-4 w-4" />;
|
||||
break;
|
||||
case "video":
|
||||
icon = <VideoIcon className="h-4 w-4" />;
|
||||
break;
|
||||
case "audio":
|
||||
icon = <FileAudioIcon className="h-4 w-4" />;
|
||||
break;
|
||||
default:
|
||||
icon = <FileIcon className="h-4 w-4" />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dynamicClass.content,
|
||||
"flex items-center justify-center rounded border",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const columnRenders: Record<string, ReactNode> = {
|
||||
content: (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{file.type === "photo" || file.type === "video" ? (
|
||||
file.thumbnail ? (
|
||||
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<PhotoColumnImage
|
||||
thumbnail={file.thumbnail}
|
||||
name={file.fileName}
|
||||
wh={dynamicClass.content}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent asChild>
|
||||
<PhotoPreview
|
||||
thumbnail={file.thumbnail}
|
||||
name={file.fileName}
|
||||
chatId={file.chatId}
|
||||
messageId={file.messageId}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</SpoiledWrapper>
|
||||
) : (
|
||||
getFileIcon(file.type)
|
||||
)
|
||||
) : (
|
||||
getFileIcon(file.type)
|
||||
)}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-center gap-2"
|
||||
onClick={onFileClick}
|
||||
>
|
||||
<FileAvatar file={file} className={dynamicClass.content} />
|
||||
</div>
|
||||
),
|
||||
type: (
|
||||
|
|
@ -165,29 +106,14 @@ export default function FileRow({
|
|||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<FileProgress file={file} downloadProgress={downloadProgress} />
|
||||
</div>
|
||||
{downloadProgress > 0 && downloadProgress !== 100 && (
|
||||
<div className="flex w-full items-end justify-between gap-2">
|
||||
<Progress
|
||||
value={downloadProgress}
|
||||
className="flex-1 rounded-none md:w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PhotoColumnImage = memo(function PhotoColumnImage({
|
||||
thumbnail,
|
||||
name,
|
||||
wh,
|
||||
}: {
|
||||
thumbnail: string;
|
||||
name: string;
|
||||
wh: string;
|
||||
}) {
|
||||
return (
|
||||
<Image
|
||||
src={`data:image/jpeg;base64,${thumbnail}`}
|
||||
alt={name ?? "File thumbnail"}
|
||||
width={32}
|
||||
height={32}
|
||||
className={cn(wh, "rounded object-cover")}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ import React from "react";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { TooltipWrapper } from "@/components/ui/tooltip";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {CheckCircle2, Clock, Download, FolderSync, Pause, XCircle} from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
FolderSync,
|
||||
Pause,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
export const DOWNLOAD_STATUS = {
|
||||
idle: {
|
||||
|
|
@ -57,7 +64,13 @@ export const TRANSFER_STATUS = {
|
|||
},
|
||||
};
|
||||
|
||||
export default function FileStatus({ file }: { file: TelegramFile }) {
|
||||
export default function FileStatus({
|
||||
file,
|
||||
className,
|
||||
}: {
|
||||
file: TelegramFile;
|
||||
className?: string;
|
||||
}) {
|
||||
const badgeVariants = {
|
||||
initial: { opacity: 0, scale: 0.9 },
|
||||
animate: {
|
||||
|
|
@ -69,7 +82,9 @@ export default function FileStatus({ file }: { file: TelegramFile }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div
|
||||
className={cn("flex items-center justify-center space-x-2", className)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{file.transferStatus === "idle" && (
|
||||
<motion.div
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"use client";
|
||||
import { FileCard } from "./file-card";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -13,16 +12,12 @@ import {
|
|||
import { type Column } from "@/components/table-column-filter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import FileNotFount from "@/components/file-not-found";
|
||||
import useIsMobile from "@/hooks/use-is-mobile";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { POST } from "@/lib/api";
|
||||
import FileRow from "@/components/file-row";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
interface FileListProps {
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
}
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import FileViewer from "@/components/file-viewer";
|
||||
|
||||
const COLUMNS: Column[] = [
|
||||
{
|
||||
|
|
@ -53,23 +48,29 @@ const COLUMNS: Column[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export function FileList({ accountId, chatId }: FileListProps) {
|
||||
const isMobile = useIsMobile();
|
||||
interface FileTableProps {
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
export function FileTable({ accountId, chatId }: FileTableProps) {
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
|
||||
const observerTarget = useRef(null);
|
||||
const tableParentRef = useRef(null);
|
||||
const [columns, setColumns] = useState<Column[]>(COLUMNS);
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage(
|
||||
"telegramFileList",
|
||||
"m",
|
||||
);
|
||||
const useFilesProps = useFiles(accountId, chatId);
|
||||
const { filters, handleFilterChange, isLoading, files, handleLoadMore } =
|
||||
useFiles(accountId, chatId);
|
||||
useFilesProps;
|
||||
const [currentViewFile, setCurrentViewFile] = useState<TelegramFile | undefined>();
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const {
|
||||
trigger: startDownloadMultiple,
|
||||
isMutating: startDownloadMultipleMutating,
|
||||
} = useSWRMutation(
|
||||
"/file/start-download-multiple",
|
||||
`/${accountId}/file/start-download-multiple`,
|
||||
(
|
||||
key,
|
||||
{
|
||||
|
|
@ -105,7 +106,6 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
},
|
||||
paddingStart: 1,
|
||||
paddingEnd: 1,
|
||||
overscan: 20,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -125,21 +125,19 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
}, [files.length, handleLoadMore, rowVirtual.getVirtualItems()]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
void handleLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
if (observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
if (files.length === 0 || !currentViewFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [handleLoadMore]);
|
||||
const index = files.findIndex((f) => f.id === currentViewFile.id);
|
||||
if (index === -1) {
|
||||
setCurrentViewFile(undefined);
|
||||
return;
|
||||
}
|
||||
const file = files[index]!;
|
||||
if (currentViewFile.next === undefined && file.next !== undefined) {
|
||||
setCurrentViewFile(file);
|
||||
}
|
||||
}, [currentViewFile, files]);
|
||||
|
||||
const dynamicClass = useMemo(() => {
|
||||
switch (rowHeight) {
|
||||
|
|
@ -161,41 +159,6 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
}
|
||||
}, [rowHeight]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FileFilters
|
||||
telegramId={accountId}
|
||||
chatId={chatId}
|
||||
filters={filters}
|
||||
onFiltersChange={handleFilterChange}
|
||||
columns={columns}
|
||||
onColumnConfigChange={setColumns}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={`${file.id}-${file.uniqueId}-${index}`}
|
||||
file={file}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div ref={observerTarget} className="h-4">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<LoaderPinwheel
|
||||
className="h-8 w-8 animate-spin"
|
||||
style={{ strokeWidth: "0.8px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedFiles.size === files.length) {
|
||||
setSelectedFiles(new Set());
|
||||
|
|
@ -232,6 +195,15 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
{currentViewFile && (
|
||||
<FileViewer
|
||||
open={viewerOpen}
|
||||
onOpenChange={setViewerOpen}
|
||||
file={currentViewFile}
|
||||
onFileChange={setCurrentViewFile}
|
||||
{...useFilesProps}
|
||||
/>
|
||||
)}
|
||||
<div className="h-[calc(100vh-13rem)] space-y-4 overflow-hidden">
|
||||
{selectedFiles.size > 0 && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
|
||||
|
|
@ -328,6 +300,10 @@ export function FileList({ accountId, chatId }: FileListProps) {
|
|||
file={file}
|
||||
checked={selectedFiles.has(file.id)}
|
||||
onCheckedChange={() => handleSelectFile(file.id)}
|
||||
onFileClick={() => {
|
||||
setCurrentViewFile(file);
|
||||
setViewerOpen(true);
|
||||
}}
|
||||
properties={{
|
||||
rowHeight: rowHeight,
|
||||
dynamicClass,
|
||||
182
web/src/components/file-viewer.tsx
Normal file
182
web/src/components/file-viewer.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { type TelegramFile } from "@/lib/types";
|
||||
import PhotoPreview from "@/components/photo-preview";
|
||||
import React, { useEffect } from "react";
|
||||
import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from "./ui/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import VideoPreview from "./video-preview";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleX,
|
||||
LoaderPinwheel,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { type useFiles } from "@/hooks/use-files";
|
||||
import FileExtra from "@/components/file-extra";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useFileSwitch from "@/hooks/use-file-switch";
|
||||
|
||||
type FileViewerProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
file: TelegramFile;
|
||||
onFileChange: (file: TelegramFile) => void;
|
||||
} & ReturnType<typeof useFiles>;
|
||||
|
||||
export default function FileViewer({
|
||||
open,
|
||||
onOpenChange,
|
||||
onFileChange,
|
||||
file,
|
||||
hasMore,
|
||||
handleLoadMore,
|
||||
isLoading,
|
||||
}: FileViewerProps) {
|
||||
const { handleNavigation, direction } = useFileSwitch({
|
||||
file,
|
||||
onFileChange,
|
||||
hasMore,
|
||||
handleLoadMore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("FileViewer rendered", file);
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (file === undefined || !open) return;
|
||||
|
||||
if (e.key === "ArrowLeft") {
|
||||
handleNavigation(-1);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
handleNavigation(1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleNavigation, file, open]);
|
||||
|
||||
const slideVariants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 1000 : -1000,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
zIndex: 0,
|
||||
x: direction < 0 ? 1000 : -1000,
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay>
|
||||
<>
|
||||
<div className="absolute left-0 top-0 z-[100] flex h-16 w-full items-center border-gray-700 px-4 text-white backdrop-blur">
|
||||
<FileExtra file={file} rowHeight="s" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4"
|
||||
>
|
||||
<CircleX className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
{file.prev && (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 top-0 flex h-full w-20 cursor-pointer items-center justify-center opacity-0 hover:opacity-100"
|
||||
onClick={() => handleNavigation(-1)}
|
||||
>
|
||||
<ChevronLeft className="h-10 w-10 text-white hover:text-accent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(file.next ?? hasMore) && (
|
||||
<div
|
||||
className="fixed bottom-0 right-0 top-0 flex h-full w-20 cursor-pointer items-center justify-center opacity-0 hover:opacity-100"
|
||||
onClick={() => handleNavigation(1)}
|
||||
>
|
||||
<ChevronRight className="h-10 w-10 text-white hover:text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</DialogOverlay>
|
||||
|
||||
<DialogPrimitive.Content
|
||||
data-fileid={file.id}
|
||||
data-prev={file.prev?.id}
|
||||
data-next={file.next?.id}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] duration-200 focus-visible:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
)}
|
||||
aria-describedby={undefined}
|
||||
onInteractOutside={(e) => {
|
||||
if (e.target instanceof Element) {
|
||||
if (e.target.getAttribute("data-state")) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="relative flex min-h-screen items-center justify-center">
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
custom={direction}
|
||||
mode="popLayout"
|
||||
>
|
||||
<motion.div
|
||||
key={file.id}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.5 },
|
||||
}}
|
||||
className="mx-auto"
|
||||
style={{
|
||||
maxWidth: "calc(100vw - 100px)",
|
||||
maxHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>File Viewer</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
{file.type === "video" &&
|
||||
file.downloadStatus === "completed" ? (
|
||||
<VideoPreview file={file} />
|
||||
) : (
|
||||
<PhotoPreview file={file} />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<LoaderPinwheel
|
||||
className="h-8 w-8 animate-spin"
|
||||
style={{ strokeWidth: "0.8px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
20
web/src/components/files.tsx
Normal file
20
web/src/components/files.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
import FileList from "@/components/mobile/file-list";
|
||||
import { FileTable } from "@/components/file-table";
|
||||
import useIsMobile from "@/hooks/use-is-mobile";
|
||||
|
||||
export default function Files({
|
||||
accountId,
|
||||
chatId,
|
||||
}: {
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isMobile) {
|
||||
return <FileList accountId={accountId} chatId={chatId} />;
|
||||
} else {
|
||||
return <FileTable accountId={accountId} chatId={chatId} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -119,11 +119,11 @@ export function Header() {
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground max-w-20 overflow-hidden">
|
||||
<span className="flex-1 text-nowrap">
|
||||
{`${prettyBytes(accountDownloadSpeed, { bits: true })}/s`}
|
||||
</span>
|
||||
<CloudDownloadIcon className="h-4 w-4" />
|
||||
<CloudDownloadIcon className="flex-shrink-0 h-4 w-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
|
|
|||
74
web/src/components/mobile/file-card.tsx
Normal file
74
web/src/components/mobile/file-card.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type { TelegramFile } from "@/lib/types";
|
||||
import { useFileSpeed } from "@/hooks/use-file-speed";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import FileAvatar from "@/components/file-avatar";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import FileStatus from "@/components/file-status";
|
||||
import FileControl from "@/components/file-control";
|
||||
import React from "react";
|
||||
import FileExtra from "@/components/file-extra";
|
||||
|
||||
type FileCardProps = {
|
||||
index: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
file: TelegramFile;
|
||||
onFileClick: () => void;
|
||||
};
|
||||
|
||||
export function FileCard({
|
||||
index,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
file,
|
||||
onFileClick,
|
||||
}: FileCardProps) {
|
||||
const { downloadProgress } = useFileSpeed(file);
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
"before:ease-[cubic-bezier(0.4, 0, 0.2, 1)] before:will-change:transform relative before:absolute before:inset-0 before:bottom-0 before:left-0 before:top-auto before:z-10 before:h-2 before:transform before:rounded-bl-xl before:bg-primary before:duration-500 before:content-['']",
|
||||
downloadProgress > 0 && downloadProgress !== 100
|
||||
? `before:w-progress`
|
||||
: "before:w-0",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
"--tw-progress-width": `${downloadProgress > 0 && downloadProgress !== 100 ? downloadProgress.toFixed(0) + "%" : "0"}`,
|
||||
...style,
|
||||
}}
|
||||
onClick={onFileClick}
|
||||
>
|
||||
<CardContent className="relative z-20 w-full p-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileAvatar file={file} className="h-16 w-16 min-w-16" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FileExtra file={file} rowHeight="s" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col justify-start gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{prettyBytes(file.size)} • {file.type}
|
||||
</span>
|
||||
<FileStatus file={file} className="justify-start" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FileControl file={file} isMobile={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
179
web/src/components/mobile/file-drawer.tsx
Normal file
179
web/src/components/mobile/file-drawer.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import type { TelegramFile } from "@/lib/types";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { LoaderPinwheel, Minimize2 } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import VideoPreview from "@/components/video-preview";
|
||||
import PhotoPreview from "@/components/photo-preview";
|
||||
import FileInfo from "@/components/mobile/file-info";
|
||||
import { type useFiles } from "@/hooks/use-files";
|
||||
import useFileSwitch from "@/hooks/use-file-switch";
|
||||
|
||||
type FileDrawerProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
file: TelegramFile;
|
||||
onFileChange: (file: TelegramFile) => void;
|
||||
} & ReturnType<typeof useFiles>;
|
||||
|
||||
export default function FileDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
file,
|
||||
onFileChange,
|
||||
hasMore,
|
||||
handleLoadMore,
|
||||
isLoading,
|
||||
}: FileDrawerProps) {
|
||||
const [viewing, setViewing] = useState(false);
|
||||
|
||||
const { handleNavigation, direction } = useFileSwitch({
|
||||
file,
|
||||
onFileChange,
|
||||
hasMore,
|
||||
handleLoadMore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
viewing &&
|
||||
(file.downloadStatus !== "completed" ||
|
||||
(file.type !== "video" && file.type !== "photo"))
|
||||
) {
|
||||
setViewing(false);
|
||||
}
|
||||
}, [file, viewing]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
const dx = touch.clientX - x;
|
||||
const dy = touch.clientY - y;
|
||||
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
if (dx > 0) {
|
||||
handleNavigation(-1);
|
||||
} else if (dx < 0) {
|
||||
handleNavigation(1);
|
||||
}
|
||||
}
|
||||
document.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
document.addEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
document.addEventListener("touchstart", handleTouchStart);
|
||||
return () => document.removeEventListener("touchstart", handleTouchStart);
|
||||
}, [handleNavigation, file]);
|
||||
|
||||
const slideVariants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 500 : -500,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
zIndex: 0,
|
||||
x: direction < 0 ? 500 : -500,
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && viewing) {
|
||||
setViewing(false);
|
||||
return;
|
||||
}
|
||||
onOpenChange(open);
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
data-fileid={file.id}
|
||||
data-prev={file.prev?.id}
|
||||
data-next={file.next?.id}
|
||||
className={cn(
|
||||
"focus:outline-none",
|
||||
viewing && "rounded-none border-none",
|
||||
)}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<DrawerTitle>File Details</DrawerTitle>
|
||||
</VisuallyHidden>
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<LoaderPinwheel
|
||||
className="h-8 w-8 animate-spin"
|
||||
style={{ strokeWidth: "0.8px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence initial={false} custom={direction} mode="popLayout">
|
||||
<motion.div
|
||||
key={file.id}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
style={{
|
||||
maxWidth: "100vw",
|
||||
maxHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
{viewing ? (
|
||||
<div className="relative flex min-h-screen items-center justify-center">
|
||||
<div className="absolute left-2 top-2 z-10">
|
||||
<Button
|
||||
onClick={() => setViewing(false)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Minimize2
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
file.type === "video" &&
|
||||
file.downloadStatus === "completed" &&
|
||||
"text-white",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{file.type === "video" &&
|
||||
file.downloadStatus === "completed" ? (
|
||||
<VideoPreview file={file} />
|
||||
) : (
|
||||
<PhotoPreview file={file} className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FileInfo onView={() => setViewing(true)} file={file} />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
157
web/src/components/mobile/file-info.tsx
Normal file
157
web/src/components/mobile/file-info.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type { TelegramFile } from "@/lib/types";
|
||||
import { useFileSpeed } from "@/hooks/use-file-speed";
|
||||
import {
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
} from "@/components/ui/drawer";
|
||||
import FileAvatar from "@/components/file-avatar";
|
||||
import FileStatus from "@/components/file-status";
|
||||
import SpoiledWrapper from "@/components/spoiled-wrapper";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Calendar,
|
||||
Clock10,
|
||||
ClockArrowDown,
|
||||
Download,
|
||||
HardDrive,
|
||||
Type,
|
||||
View,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MobileFileControl } from "@/components/file-control";
|
||||
import React from "react";
|
||||
|
||||
export default function FileInfo({
|
||||
file,
|
||||
onView,
|
||||
}: {
|
||||
file: TelegramFile;
|
||||
onView: () => void;
|
||||
}) {
|
||||
const { downloadProgress } = useFileSpeed(file);
|
||||
return (
|
||||
<>
|
||||
<DrawerHeader className="text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-1">
|
||||
<FileAvatar file={file} className="w-[calc(100vw-20rem)]" />
|
||||
<span className="max-w-64 truncate">{file.fileName}</span>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<FileStatus file={file} />
|
||||
</div>
|
||||
{file.caption && (
|
||||
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
|
||||
<DrawerDescription
|
||||
className="mx-auto mt-2 max-h-48 max-w-md overflow-y-auto text-start"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: file.caption.replaceAll("\n", "<br />"),
|
||||
}}
|
||||
></DrawerDescription>
|
||||
</SpoiledWrapper>
|
||||
)}
|
||||
{downloadProgress > 0 && downloadProgress !== 100 && (
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<Progress
|
||||
value={downloadProgress}
|
||||
className="flex-1 rounded-none md:w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="px-2">
|
||||
<Separator className="my-2" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Type className="h-4 w-4" />
|
||||
<span>Type</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{file.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span>Size</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{prettyBytes(file.size)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Received At</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatDistanceToNow(new Date(file.date * 1000), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>Downloaded Size</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{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>
|
||||
|
||||
<DrawerFooter>
|
||||
{file.downloadStatus === "completed" &&
|
||||
(file.type === "video" || file.type === "photo") && (
|
||||
<Button className="w-full" onClick={onView}>
|
||||
<View className="h-4 w-4" />
|
||||
<span>View</span>
|
||||
</Button>
|
||||
)}
|
||||
<MobileFileControl file={file} />
|
||||
</DrawerFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
web/src/components/mobile/file-list.tsx
Normal file
139
web/src/components/mobile/file-list.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { FileFilters } from "@/components/file-filters";
|
||||
import { LoaderPinwheel } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useFiles } from "@/hooks/use-files";
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import { FileCard } from "@/components/mobile/file-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import FileDrawer from "@/components/mobile/file-drawer";
|
||||
import type { TelegramFile } from "@/lib/types";
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
interface FileListProps {
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
export default function FileList({ accountId, chatId }: FileListProps) {
|
||||
const useFilesProps = useFiles(accountId, chatId);
|
||||
const [currentViewFile, setCurrentViewFile] = useState<
|
||||
TelegramFile | undefined
|
||||
>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
isLoading,
|
||||
files,
|
||||
hasMore,
|
||||
handleLoadMore,
|
||||
} = useFilesProps;
|
||||
|
||||
const rowVirtual = useWindowVirtualizer({
|
||||
count: hasMore ? files.length + 1 : files.length,
|
||||
estimateSize: () => 90,
|
||||
overscan: 5,
|
||||
scrollMargin: 0,
|
||||
gap: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const [lastItem] = [...rowVirtual.getVirtualItems()].reverse();
|
||||
if (!lastItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastItem.index >= files.length - 1 && hasMore && !isLoading) {
|
||||
void handleLoadMore();
|
||||
}
|
||||
//eslint-disable-next-line
|
||||
}, [files.length, handleLoadMore, rowVirtual.getVirtualItems()]);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length === 0 || !currentViewFile) {
|
||||
return;
|
||||
}
|
||||
const index = files.findIndex((f) => f.id === currentViewFile.id);
|
||||
if (index === -1) {
|
||||
setCurrentViewFile(undefined);
|
||||
return;
|
||||
}
|
||||
const file = files[index]!;
|
||||
if (!isEqual(file, currentViewFile)) {
|
||||
setCurrentViewFile(file);
|
||||
}
|
||||
}, [currentViewFile, files]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FileFilters
|
||||
telegramId={accountId}
|
||||
chatId={chatId}
|
||||
filters={filters}
|
||||
onFiltersChange={handleFilterChange}
|
||||
/>
|
||||
{currentViewFile && (
|
||||
<FileDrawer
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
file={currentViewFile}
|
||||
onFileChange={setCurrentViewFile}
|
||||
{...useFilesProps}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtual.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{files.length !== 0 &&
|
||||
rowVirtual.getVirtualItems().map((virtualRow) => {
|
||||
const isLoaderRow = virtualRow.index > files.length - 1;
|
||||
const file = files[virtualRow.index]!;
|
||||
if (isLoaderRow) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex w-full items-center justify-center"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
key="loader"
|
||||
>
|
||||
{hasMore ? (
|
||||
<LoaderPinwheel
|
||||
className="h-8 w-8 animate-spin"
|
||||
style={{ strokeWidth: "0.8px" }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No more files</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileCard
|
||||
key={`${file.id}-${file.uniqueId}-${virtualRow.index}`}
|
||||
index={virtualRow.index}
|
||||
className={cn("absolute left-0 top-0 flex w-full items-center")}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
ref={rowVirtual.measureElement}
|
||||
file={file}
|
||||
onFileClick={() => {
|
||||
setCurrentViewFile(file);
|
||||
setIsDrawerOpen(true);
|
||||
}}
|
||||
{...useFilesProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,169 +1,92 @@
|
|||
"use client";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { CloudAlert, Loader } from "lucide-react";
|
||||
import { useWebsocket } from "@/hooks/use-websocket";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import { WebSocketMessageType } from "@/lib/websocket-types";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { type TDFile } from "@/lib/types";
|
||||
import { type TelegramFile } from "@/lib/types";
|
||||
import { getApiUrl } from "@/lib/api";
|
||||
import useIsMobile from "@/hooks/use-is-mobile";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ImageOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ImageErrorFallback = ({
|
||||
className = "",
|
||||
message = "Image loading failed!",
|
||||
}) => (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded bg-gray-100 p-4 ${className}`}
|
||||
>
|
||||
<ImageOff className="mb-2 h-8 w-8 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PhotoPreviewProps {
|
||||
thumbnail: string;
|
||||
name: string;
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
className?: string;
|
||||
file: TelegramFile;
|
||||
}
|
||||
|
||||
export default function PhotoPreview({
|
||||
thumbnail,
|
||||
name,
|
||||
chatId,
|
||||
messageId,
|
||||
}: PhotoPreviewProps) {
|
||||
const url = `${getApiUrl()}/file/preview?chatId=${chatId}&messageId=${messageId}`;
|
||||
const { settings } = useSettings();
|
||||
const isMobile = useIsMobile();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [fileStatus, setFileStatus] = useState<{
|
||||
fileId: number | null;
|
||||
readyFileIds: number[];
|
||||
}>({
|
||||
fileId: null,
|
||||
readyFileIds: [],
|
||||
});
|
||||
const { lastJsonMessage } = useWebsocket();
|
||||
|
||||
const { error } = useSWR<void, Error>(
|
||||
settings?.needToLoadImages === "true" ? url : null,
|
||||
(url: string) =>
|
||||
fetch(url, { credentials: "include" }).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
let message = "Failed to fetch, status: " + res.status;
|
||||
try {
|
||||
const { error } = await res.json() as { error: string };
|
||||
if (error) {
|
||||
message = error;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
toast({
|
||||
title: "Error",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
throw new Error(message);
|
||||
}
|
||||
if (res.headers.get("Content-Type") === "application/json") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const json: { fileId: number } = await res.json();
|
||||
if (json.fileId) {
|
||||
setFileStatus((prev) => ({
|
||||
...prev,
|
||||
fileId: json.fileId,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setIsReady(true);
|
||||
}
|
||||
}),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateOnMount: true,
|
||||
},
|
||||
);
|
||||
export default function PhotoPreview({ className, file }: PhotoPreviewProps) {
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [error, setError] = useState(false);
|
||||
const src =
|
||||
!file.localPath || file.type === "video"
|
||||
? `data:image/jpeg;base64,${file.thumbnail}`
|
||||
: `${getApiUrl()}/${file.telegramId}/file/${file.uniqueId}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
lastJsonMessage !== null &&
|
||||
lastJsonMessage.type === WebSocketMessageType.FILE_UPDATE
|
||||
) {
|
||||
const { file } = lastJsonMessage.data as { file: TDFile };
|
||||
if (file.local?.isDownloadingCompleted) {
|
||||
setFileStatus((prev) => ({
|
||||
...prev,
|
||||
readyFileIds: [...prev.readyFileIds, file.id],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [lastJsonMessage]);
|
||||
setViewportHeight(window.innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const { fileId, readyFileIds } = fileStatus;
|
||||
if (fileId !== null && readyFileIds.includes(fileId)) {
|
||||
setIsReady(true);
|
||||
}
|
||||
}, [fileStatus]);
|
||||
const handleResize = () => {
|
||||
setViewportHeight(window.innerHeight);
|
||||
};
|
||||
|
||||
const ThumbnailImage = (
|
||||
<div className="rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
|
||||
<Image
|
||||
src={`data:image/jpeg;base64,${thumbnail}`}
|
||||
alt={name ?? "Photo Thumbnail"}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-[200px] w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
if (settings?.needToLoadImages !== "true") {
|
||||
return ThumbnailImage;
|
||||
const handleError = () => {
|
||||
setError(true);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <ImageErrorFallback className="h-full min-h-[200px] w-full" />;
|
||||
}
|
||||
|
||||
if (!isReady || error) {
|
||||
if (file?.extra?.width && file?.extra?.height && file?.localPath) {
|
||||
const aspectRatio = file.extra.width / file.extra.height;
|
||||
const maxHeight = viewportHeight;
|
||||
const calculatedHeight = Math.min(file.extra.height, maxHeight);
|
||||
const calculatedWidth = calculatedHeight * aspectRatio;
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
|
||||
{ThumbnailImage}
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
|
||||
{error ? (
|
||||
<CloudAlert className="h-8 w-8" />
|
||||
) : (
|
||||
!isReady && <Loader className="h-8 w-8 animate-spin text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn("relative", className)}
|
||||
style={{ maxHeight: `${maxHeight}px` }}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
placeholder="blur"
|
||||
unoptimized={true}
|
||||
blurDataURL={`data:image/jpeg;base64,${file.thumbnail}`}
|
||||
alt={file.fileName ?? "Photo"}
|
||||
width={calculatedWidth}
|
||||
height={calculatedHeight}
|
||||
className={cn("h-auto max-h-screen w-auto object-contain")}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
if (!isMobile) return;
|
||||
if (!document.fullscreenEnabled) {
|
||||
console.error("Full-screen mode is not supported.");
|
||||
return;
|
||||
}
|
||||
const image = imageRef.current;
|
||||
if (!image) return;
|
||||
if (!document.fullscreenElement) {
|
||||
image.requestFullscreen().catch((err: Error) => {
|
||||
console.error(`Error attempting to enable full-screen mode: ${err.message}`)
|
||||
});
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
void document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
|
||||
<div className={cn("relative h-60 w-60", className)}>
|
||||
<Image
|
||||
ref={imageRef}
|
||||
src={url}
|
||||
src={src}
|
||||
placeholder="blur"
|
||||
unoptimized={true}
|
||||
blurDataURL={`data:image/jpeg;base64,${thumbnail}`}
|
||||
alt={name ?? "Photo"}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-[200px] w-full"
|
||||
onClick={toggleFullScreen}
|
||||
blurDataURL={`data:image/jpeg;base64,${file.thumbnail}`}
|
||||
alt={file.fileName ?? "Photo"}
|
||||
fill={true}
|
||||
className="h-auto w-auto object-contain"
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,18 +20,6 @@ export default function SettingsForm() {
|
|||
const { account } = useTelegramAccount();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const imageLoadSizeOptions = [
|
||||
{ value: "s", label: "box 100x100" },
|
||||
{ value: "m", label: "box 320x320" },
|
||||
{ value: "x", label: "box 800x800" },
|
||||
{ value: "y", label: "box 1280x1280" },
|
||||
{ value: "w", label: "box 2560x2560" },
|
||||
{ value: "a", label: "crop 160x160" },
|
||||
{ value: "b", label: "crop 320x320" },
|
||||
{ value: "c", label: "crop 640x640" },
|
||||
{ value: "d", label: "crop 1280x1280" },
|
||||
];
|
||||
|
||||
const avgSpeedIntervalOptions = [
|
||||
{ value: "60", label: "1 minute" },
|
||||
{ value: "300", label: "5 minutes" },
|
||||
|
|
@ -91,43 +79,6 @@ export default function SettingsForm() {
|
|||
form will be inaccurate.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="need-load-preview-images">
|
||||
Need Load Preview Images
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="need-load-preview-images"
|
||||
checked={settings?.needToLoadImages === "true"}
|
||||
onCheckedChange={(checked) => {
|
||||
void setSetting("needToLoadImages", String(checked));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{settings?.needToLoadImages === "true" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image-load-size">Load Size</Label>
|
||||
<Select
|
||||
value={settings.imageLoadSize}
|
||||
onValueChange={(v) => void setSetting("imageLoadSize", v)}
|
||||
>
|
||||
<SelectTrigger id="image-load-size">
|
||||
<SelectValue placeholder="Select Image Load Size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{imageLoadSizeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The size of the image to load in the browser.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="always-hide">Always Hide</Label>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ const DrawerContent = React.forwardRef<
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
|
|
|
|||
28
web/src/components/ui/slider.tsx
Normal file
28
web/src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
571
web/src/components/video-preview.tsx
Normal file
571
web/src/components/video-preview.tsx
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
import { type TelegramFile } from "@/lib/types";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Maximize,
|
||||
Minimize,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
VideoOff,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { getApiUrl } from "@/lib/api";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useIsMobile from "@/hooks/use-is-mobile";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
const VideoErrorFallback = ({
|
||||
className = "",
|
||||
message = "Video loading failed!",
|
||||
}) => (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded bg-gray-100 p-4 ${className}`}
|
||||
>
|
||||
<VideoOff className="mb-2 h-8 w-8 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-gray-600/40">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-white" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-4 border-white bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
|
||||
Slider.displayName = "Slider";
|
||||
|
||||
const DesktopControls = ({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
playbackRate,
|
||||
onPlayPause,
|
||||
onVolumeChange,
|
||||
onMuteToggle,
|
||||
onFullscreenToggle,
|
||||
onPlaybackRateChange,
|
||||
onSeek,
|
||||
progressBarRef,
|
||||
onProgressBarHover,
|
||||
onProgressBarLeave,
|
||||
showPreview,
|
||||
previewTime,
|
||||
previewPos,
|
||||
canvasRef,
|
||||
}: {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
playbackRate: number;
|
||||
onPlayPause: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onMuteToggle: () => void;
|
||||
onFullscreenToggle: () => void;
|
||||
onPlaybackRateChange: (rate: number) => void;
|
||||
onSeek: (time: number) => void;
|
||||
progressBarRef: React.RefObject<HTMLDivElement>;
|
||||
onProgressBarHover: (e: React.MouseEvent) => void;
|
||||
onProgressBarLeave: () => void;
|
||||
showPreview: boolean;
|
||||
previewTime: number;
|
||||
previewPos: number;
|
||||
canvasRef: React.RefObject<HTMLCanvasElement>;
|
||||
}) => {
|
||||
const playbackRates = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="relative"
|
||||
onMouseMove={onProgressBarHover}
|
||||
onMouseLeave={onProgressBarLeave}
|
||||
>
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
max={duration}
|
||||
step={0.1}
|
||||
className="w-full cursor-pointer"
|
||||
onValueChange={(value) => value[0] && onSeek(value[0])}
|
||||
/>
|
||||
{showPreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute bottom-full mb-4 overflow-hidden bg-black"
|
||||
style={{ left: `${previewPos}px`, transform: "translateX(-50%)" }}
|
||||
>
|
||||
<div className="flex aspect-video w-48 items-center justify-center bg-black">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-black/80 px-2 py-1 text-center text-sm text-white">
|
||||
{formatTime(previewTime)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={onPlayPause}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-6 w-6" />
|
||||
) : (
|
||||
<Play className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={onMuteToggle}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="h-6 w-6" />
|
||||
) : (
|
||||
<Volume2 className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
<Slider
|
||||
value={[volume * 100]}
|
||||
max={100}
|
||||
className="w-24"
|
||||
onValueChange={(value) => onVolumeChange(value[0]! / 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
{playbackRate}x
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-18 p-0" modal={true} side="top">
|
||||
<div className="flex flex-col">
|
||||
{playbackRates.map((rate) => (
|
||||
<Button
|
||||
key={rate}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"justify-start rounded-none",
|
||||
rate === playbackRate && "bg-accent",
|
||||
)}
|
||||
onClick={() => onPlaybackRateChange(rate)}
|
||||
>
|
||||
{rate}x
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={onFullscreenToggle}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="h-6 w-6" />
|
||||
) : (
|
||||
<Maximize className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileControls = ({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
onPlayPause,
|
||||
onSeek,
|
||||
onSkipForward,
|
||||
onSkipBackward,
|
||||
}: {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onPlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onSkipForward: () => void;
|
||||
onSkipBackward: () => void;
|
||||
}) => {
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<span className="text-sm text-white">{formatTime(currentTime)}</span>
|
||||
<span className="text-sm text-white">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
max={duration}
|
||||
step={0.1}
|
||||
className="w-full cursor-pointer"
|
||||
onValueChange={(value) => value[0] && onSeek(value[0])}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={onSkipBackward}
|
||||
>
|
||||
<RotateCcw className="h-8 w-8" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={onPlayPause}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-12 w-12" />
|
||||
) : (
|
||||
<Play className="h-12 w-12" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={onSkipForward}
|
||||
>
|
||||
<RotateCw className="h-8 w-8" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPreview = ({
|
||||
file,
|
||||
onTimeUpdate,
|
||||
onVolumeChange,
|
||||
className,
|
||||
}: {
|
||||
file: TelegramFile;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onVolumeChange?: (volume: number) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewTime, setPreviewTime] = useState(0);
|
||||
const [previewPos, setPreviewPos] = useState(0);
|
||||
const [isPreviewReady, setIsPreviewReady] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const url = `${getApiUrl()}/${file.telegramId}/file/${file.uniqueId}`;
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const previewVideo = previewVideoRef.current;
|
||||
if (!video || !previewVideo) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
setIsPreviewReady(true);
|
||||
};
|
||||
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
previewVideo.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
previewVideo.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const captureVideoFrame = () => {
|
||||
const previewVideo = previewVideoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!previewVideo || !canvas || !isPreviewReady) return;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
// Set canvas dimensions to match video dimensions
|
||||
canvas.width = previewVideo.videoWidth;
|
||||
canvas.height = previewVideo.videoHeight;
|
||||
|
||||
// Draw the current frame
|
||||
context.drawImage(previewVideo, 0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showPreview) {
|
||||
const timeoutId = setTimeout(captureVideoFrame, 150); // Add slight delay for frame to load
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [previewTime, showPreview]);
|
||||
|
||||
const handleSkipForward = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = Math.min(duration, currentTime + 15);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipBackward = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = Math.max(0, currentTime - 15);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressBarHover = (e: React.MouseEvent) => {
|
||||
if (isMobile) return;
|
||||
|
||||
const progressBar = progressBarRef.current;
|
||||
const previewVideo = previewVideoRef.current;
|
||||
if (!progressBar || !previewVideo || !isPreviewReady) return;
|
||||
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const percent = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
const previewTimeValue = percent * duration;
|
||||
|
||||
setPreviewTime(previewTimeValue);
|
||||
setPreviewPos(e.clientX - rect.left);
|
||||
setShowPreview(true);
|
||||
previewVideo.currentTime = previewTimeValue;
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
void videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (time: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
onTimeUpdate?.(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (newVolume: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
onVolumeChange?.(newVolume);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (videoRef.current) {
|
||||
const newMuted = !isMuted;
|
||||
videoRef.current.muted = newMuted;
|
||||
setIsMuted(newMuted);
|
||||
if (newMuted) {
|
||||
handleVolumeChange(0);
|
||||
} else {
|
||||
handleVolumeChange(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
void containerRef.current.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
void document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaybackRateChange = (rate: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = rate;
|
||||
setPlaybackRate(rate);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setCurrentTime(0);
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <VideoErrorFallback className="h-full min-h-[200px] w-full" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden bg-black",
|
||||
isMobile && "flex h-screen w-screen items-center",
|
||||
)}
|
||||
onClick={isMobile ? () => setShowControls((prev) => !prev) : undefined}
|
||||
onMouseEnter={() => setShowControls(true)}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay={isMobile}
|
||||
onPlay={() => isMobile && !isPlaying && setIsPlaying(true)}
|
||||
onEnded={handleEnded}
|
||||
onError={() => setError(true)}
|
||||
src={url}
|
||||
playsInline
|
||||
className={cn("max-h-[calc(100vh-5rem)] w-full", className)}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
/>
|
||||
|
||||
{/* Hidden video for preview */}
|
||||
{!isMobile && (
|
||||
<video
|
||||
ref={previewVideoRef}
|
||||
src={url}
|
||||
className="hidden"
|
||||
preload="auto"
|
||||
/>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showControls && (
|
||||
<motion.div
|
||||
id="video-controls"
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4",
|
||||
isMobile && "bg-gray-900 bg-opacity-20 pb-16",
|
||||
)}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MobileControls
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onPlayPause={togglePlay}
|
||||
onSeek={handleSeek}
|
||||
onSkipForward={handleSkipForward}
|
||||
onSkipBackward={handleSkipBackward}
|
||||
/>
|
||||
) : (
|
||||
<DesktopControls
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isFullscreen={isFullscreen}
|
||||
playbackRate={playbackRate}
|
||||
onPlayPause={togglePlay}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onMuteToggle={toggleMute}
|
||||
onFullscreenToggle={toggleFullscreen}
|
||||
onPlaybackRateChange={handlePlaybackRateChange}
|
||||
onSeek={handleSeek}
|
||||
progressBarRef={progressBarRef}
|
||||
onProgressBarHover={handleProgressBarHover}
|
||||
onProgressBarLeave={() => setShowPreview(false)}
|
||||
showPreview={showPreview}
|
||||
previewTime={previewTime}
|
||||
previewPos={previewPos}
|
||||
canvasRef={canvasRef}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPreview;
|
||||
|
|
@ -5,24 +5,24 @@ import { toast } from "@/hooks/use-toast";
|
|||
|
||||
export function useFileControl(file: TelegramFile) {
|
||||
const { trigger: startDownload, isMutating: starting } = useSWRMutation(
|
||||
"/file/start-download",
|
||||
`/${file.telegramId}/file/start-download`,
|
||||
(
|
||||
key,
|
||||
{ arg }: { arg: { chatId: number; messageId: number; fileId: number } },
|
||||
) => POST(key, arg),
|
||||
);
|
||||
const { trigger: cancelDownload, isMutating: cancelling } = useSWRMutation(
|
||||
"/file/cancel-download",
|
||||
`/${file.telegramId}/file/cancel-download`,
|
||||
(key, { arg }: { arg: { fileId: number } }) => POST(key, arg),
|
||||
);
|
||||
const { trigger: togglePauseDownload, isMutating: togglingPause } =
|
||||
useSWRMutation(
|
||||
"/file/toggle-pause-download",
|
||||
`/${file.telegramId}/file/toggle-pause-download`,
|
||||
(key, { arg }: { arg: { fileId: number; isPaused: boolean } }) =>
|
||||
POST(key, arg),
|
||||
);
|
||||
const { trigger: removeFile, isMutating: removing } = useSWRMutation(
|
||||
"/file/remove",
|
||||
`/${file.telegramId}/file/remove`,
|
||||
(key, { arg }: { arg: { fileId: number } }) => POST(key, arg),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useWebsocket } from "@/hooks/use-websocket";
|
||||
import { useEffect, useState } from "react";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import { WebSocketMessageType } from "@/lib/websocket-types";
|
||||
import type { TDFile } from "@/lib/types";
|
||||
import type {TDFile, TelegramFile} from "@/lib/types";
|
||||
import { env } from "@/env";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export function useFileSpeed(fileId: number) {
|
||||
export function useFileSpeed(file: TelegramFile) {
|
||||
const fileId = file.id;
|
||||
const { lastJsonMessage } = useWebsocket();
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [downloadSpeed, setDownloadSpeed] = useState({
|
||||
|
|
@ -19,11 +20,25 @@ export function useFileSpeed(fileId: number) {
|
|||
maxWait: 1000,
|
||||
});
|
||||
|
||||
const [debounceProgress] = useDebounce(downloadProgress, 300, {
|
||||
const [debounceProgress] = useDebounce(downloadProgress, 100, {
|
||||
leading: true,
|
||||
maxWait: 1000,
|
||||
});
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const fileDownloadProgress =
|
||||
file.size > 0
|
||||
? Math.min((file.downloadedSize / file.size) * 100, 100)
|
||||
: 0;
|
||||
if (file.downloadStatus === "downloading") {
|
||||
return debounceProgress > 0 ? debounceProgress : fileDownloadProgress;
|
||||
}
|
||||
if (file.downloadStatus === "paused") {
|
||||
return fileDownloadProgress;
|
||||
}
|
||||
return file.downloadStatus === "completed" ? 100 : 0;
|
||||
}, [file.downloadStatus, file.downloadedSize, file.size, debounceProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
lastJsonMessage !== null &&
|
||||
|
|
@ -99,7 +114,7 @@ export function useFileSpeed(fileId: number) {
|
|||
}, []);
|
||||
|
||||
return {
|
||||
downloadProgress: debounceProgress,
|
||||
downloadProgress: progress,
|
||||
downloadSpeed: debounceSpeed,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
43
web/src/hooks/use-file-switch.ts
Normal file
43
web/src/hooks/use-file-switch.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { TelegramFile } from "@/lib/types";
|
||||
|
||||
type FileSwitchProps = {
|
||||
file: TelegramFile;
|
||||
onFileChange: (file: TelegramFile) => void;
|
||||
hasMore: boolean;
|
||||
handleLoadMore: () => Promise<void>;
|
||||
};
|
||||
|
||||
export default function useFileSwitch({
|
||||
file,
|
||||
onFileChange,
|
||||
hasMore,
|
||||
handleLoadMore,
|
||||
}: FileSwitchProps) {
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
const handleNavigation = useCallback(
|
||||
(newDirection: number) => {
|
||||
const newFile = newDirection > 0 ? file?.next : file?.prev;
|
||||
if (newFile) {
|
||||
setDirection(newDirection);
|
||||
onFileChange(newFile);
|
||||
} else if (newDirection > 0) {
|
||||
if (hasMore) {
|
||||
void handleLoadMore();
|
||||
} else {
|
||||
toast({
|
||||
description: "No more files to load",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[file?.next, file?.prev, onFileChange, hasMore, handleLoadMore],
|
||||
);
|
||||
|
||||
return {
|
||||
direction,
|
||||
handleNavigation,
|
||||
};
|
||||
}
|
||||
|
|
@ -154,6 +154,10 @@ export function useFiles(accountId: string, chatId: string) {
|
|||
});
|
||||
});
|
||||
});
|
||||
files.forEach((file, index) => {
|
||||
file.prev = files[index - 1];
|
||||
file.next = files[index + 1];
|
||||
});
|
||||
return files;
|
||||
}, [pages, latestFilesStatus]);
|
||||
|
||||
|
|
|
|||
|
|
@ -65,10 +65,6 @@ export const TelegramChatProvider: React.FC<TelegramChatProviderProps> = ({
|
|||
return;
|
||||
}
|
||||
router.push(`/account/${accountId}/${newChatId}`);
|
||||
toast({
|
||||
title: "Chat Selected",
|
||||
description: `Viewing files from ${chat.name}`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export type TransferStatus = "idle" | "transferring" | "completed" | "error";
|
|||
|
||||
export type TelegramFile = {
|
||||
id: number;
|
||||
telegramId: number;
|
||||
uniqueId: string;
|
||||
messageId: number;
|
||||
chatId: number;
|
||||
|
|
@ -52,6 +53,23 @@ export type TelegramFile = {
|
|||
startDate: number;
|
||||
completionDate: number;
|
||||
transferStatus?: TransferStatus;
|
||||
extra?: PhotoExtra | VideoExtra;
|
||||
|
||||
prev?: TelegramFile;
|
||||
next?: TelegramFile;
|
||||
};
|
||||
|
||||
export type PhotoExtra = {
|
||||
width: number;
|
||||
height: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type VideoExtra = {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type TDFile = {
|
||||
|
|
@ -90,7 +108,6 @@ export type TelegramApiResult = {
|
|||
|
||||
export const SettingKeys = [
|
||||
"uniqueOnly",
|
||||
"needToLoadImages",
|
||||
"imageLoadSize",
|
||||
"alwaysHide",
|
||||
"showSensitiveContent",
|
||||
|
|
|
|||
|
|
@ -64,3 +64,7 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.before\:w-progress::before {
|
||||
width: var(--tw-progress-width);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ export default {
|
|||
"5": "hsl(var(--chart-5))",
|
||||
},
|
||||
},
|
||||
width: {
|
||||
progress: "var(--tw-progress-width)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcss_animate],
|
||||
|
|
|
|||
Loading…
Reference in a new issue