feat: Add more search criteria.

This commit is contained in:
jarvis2f 2025-02-18 20:29:23 +08:00
parent 493392ed07
commit 4a907a611d
16 changed files with 1820 additions and 310 deletions

View file

@ -75,4 +75,14 @@ public class MessyUtils {
});
return completableFuture.join();
}
public static long convertToByte(long value, String unit) {
return switch (unit) {
case "B" -> value;
case "KB" -> value * 1024;
case "MB" -> value * 1024 * 1024;
case "GB" -> value * 1024 * 1024 * 1024;
default -> throw new IllegalArgumentException("Unknown unit: " + unit);
};
}
}

View file

@ -175,8 +175,8 @@ public class TelegramVerticle extends AbstractVerticle {
}
public Future<JsonObject> getChatFiles(long chatId, Map<String, String> filter) {
String downloadStatus = filter.get("downloadStatus");
if (Arrays.asList("downloading", "paused", "completed", "error").contains(downloadStatus)) {
boolean offline = Convert.toBool(filter.get("offline"), false);
if (offline) {
return DataVerticle.fileRepository.getFiles(chatId, filter)
.compose(r -> {
long[] messageIds = r.v1.stream().mapToLong(FileRecord::messageId).toArray();

View file

@ -21,6 +21,10 @@ import telegram.files.MessyUtils;
import telegram.files.repository.FileRecord;
import telegram.files.repository.FileRepository;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@ -83,16 +87,35 @@ public class FileRepositoryImpl implements FileRepository {
@Override
public Future<Tuple3<List<FileRecord>, Long, Long>> getFiles(long chatId, Map<String, String> filter) {
String search = filter.get("search");
String type = filter.get("type");
String downloadStatus = filter.get("downloadStatus");
String transferStatus = filter.get("transferStatus");
String search = filter.get("search");
String dateType = filter.get("dateType");
String dateRange = filter.get("dateRange");
String sizeRange = filter.get("sizeRange");
String sizeUnit = filter.get("sizeUnit");
String sort = filter.get("sort");
String order = filter.get("order");
Long fromMessageId = Convert.toLong(filter.get("fromMessageId"), 0L);
String type = filter.get("type");
int limit = Convert.toInt(filter.get("limit"), 20);
String whereClause = "chat_id = #{chatId}";
Map<String, Object> params = MapUtil.of("chatId", chatId);
params.put("limit", limit);
if (StrUtil.isNotBlank(search)) {
whereClause += " AND (file_name LIKE #{search} OR caption LIKE #{search})";
params.put("search", "%%" + search + "%%");
}
if (StrUtil.isNotBlank(type) && !Objects.equals(type, "all")) {
if (Objects.equals(type, "media")) {
whereClause += " AND type IN ('photo', 'video')";
} else {
whereClause += " AND type = #{type}";
params.put("type", type);
}
}
if (StrUtil.isNotBlank(downloadStatus)) {
whereClause += " AND download_status = #{downloadStatus}";
params.put("downloadStatus", downloadStatus);
@ -101,16 +124,34 @@ public class FileRepositoryImpl implements FileRepository {
whereClause += " AND transfer_status = #{transferStatus}";
params.put("transferStatus", transferStatus);
}
if (StrUtil.isNotBlank(search)) {
whereClause += " AND (file_name LIKE #{search} OR caption LIKE #{search})";
params.put("search", "%%" + search + "%%");
if (StrUtil.isNotBlank(dateType) && StrUtil.isNotBlank(dateRange)) {
String[] dates = dateRange.split(",");
if (dates.length == 2) {
long startTime = LocalDate.parse(dates[0], DateTimeFormatter.ISO_DATE)
.atStartOfDay()
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
long endTime = LocalDate.parse(dates[1], DateTimeFormatter.ISO_DATE)
.atTime(LocalTime.MAX)
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
if (Objects.equals(dateType, "sent")) {
whereClause += " AND date >= #{startTime} AND date <= #{endTime}";
startTime = startTime / 1000;
endTime = endTime / 1000;
} else {
whereClause += " AND completion_date >= #{startTime} AND completion_date <= #{endTime}";
}
params.put("startTime", startTime);
params.put("endTime", endTime);
}
}
if (StrUtil.isNotBlank(type)) {
if (Objects.equals(type, "media")) {
whereClause += " AND type IN ('photo', 'video')";
} else {
whereClause += " AND type = #{type}";
params.put("type", type);
if (StrUtil.isNotBlank(sizeRange) && StrUtil.isNotBlank(sizeUnit)) {
String[] sizes = sizeRange.split(",");
if (sizes.length == 2) {
long minSize = MessyUtils.convertToByte(Convert.toLong(sizes[0]), sizeUnit);
long maxSize = MessyUtils.convertToByte(Convert.toLong(sizes[1]), sizeUnit);
whereClause += " AND size >= #{minSize} AND size <= #{maxSize}";
params.put("minSize", minSize);
params.put("maxSize", maxSize);
}
}
String countClause = whereClause;
@ -118,11 +159,16 @@ public class FileRepositoryImpl implements FileRepository {
whereClause += " AND message_id < #{fromMessageId}";
params.put("fromMessageId", fromMessageId);
}
String orderBy = "message_id DESC";
if (StrUtil.isNotBlank(sort) && StrUtil.isNotBlank(order)) {
orderBy = "%s %s".formatted(sort, order);
}
log.trace("Get files with where: %s params: %s".formatted(whereClause, params));
return Future.all(
SqlTemplate
.forQuery(pool, """
SELECT * FROM file_record WHERE %s ORDER BY message_id desc LIMIT #{limit}
""".formatted(whereClause))
SELECT * FROM file_record WHERE %s ORDER BY %s LIMIT #{limit}
""".formatted(whereClause, orderBy))
.mapTo(FileRecord.ROW_MAPPER)
.execute(params)
.onFailure(err -> log.error("Failed to get file record: %s".formatted(err.getMessage())))

719
web/package-lock.json generated
View file

@ -1,15 +1,14 @@
{
"name": "telegram-files-web",
"version": "0.1.13",
"version": "0.1.14",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "telegram-files-web",
"version": "0.1.13",
"version": "0.1.14",
"dependencies": {
"@dnd-kit/core": "^6.2.0",
"@dnd-kit/modifiers": "^8.0.0",
"@dnd-kit/sortable": "^9.0.0",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.2",
@ -20,11 +19,13 @@
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-portal": "^1.1.4",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.3",
"@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-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
@ -35,7 +36,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.15.0",
"geist": "^1.3.0",
"input-otp": "^1.4.1",
@ -48,6 +49,7 @@
"pretty-bytes": "^6.1.1",
"qr-code-styling": "^1.9.1",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"react-use-websocket": "^4.11.1",
@ -124,19 +126,6 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-8.0.0.tgz",
"integrity": "sha512-oPZ0JoKtVSK9hVHSBDKG1oCLgnZbpxuH/SMnyDtXkNhn9+SF1+98DlWILFYxIT8faOS/GgfhlNFREmym6oqrEg==",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.2.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-9.0.0.tgz",
@ -1318,6 +1307,24 @@
}
}
},
"node_modules/@radix-ui/react-collection/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",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"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-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
@ -1966,6 +1973,48 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "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-menu/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",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"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-popover": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz",
@ -2002,6 +2051,48 @@
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "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-popover/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",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"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-popper": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
@ -2048,11 +2139,12 @@
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
@ -2070,6 +2162,29 @@
}
}
},
"node_modules/@radix-ui/react-portal/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-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
@ -2115,6 +2230,24 @@
}
}
},
"node_modules/@radix-ui/react-primitive/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",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"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-progress": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.0.tgz",
@ -2152,6 +2285,163 @@
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz",
"integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@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-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-roving-focus": "1.1.2",
"@radix-ui/react-use-controllable-state": "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-radio-group/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-radio-group/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-radio-group/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-radio-group/node_modules/@radix-ui/react-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "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-radio-group/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-radio-group/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
"integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==",
"license": "MIT",
"dependencies": {
"@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-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "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-roving-focus": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",
@ -2238,6 +2528,48 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "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-select/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",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"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-separator": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz",
@ -2416,7 +2748,7 @@
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
"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==",
@ -2434,13 +2766,11 @@
}
}
},
"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",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"node_modules/@radix-ui/react-slot/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"
@ -2604,6 +2934,30 @@
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "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-toggle": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.1.tgz",
@ -2719,6 +3073,48 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "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-tooltip/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",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"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-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@ -4374,9 +4770,10 @@
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@ -7132,6 +7529,20 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@ -8635,15 +9046,6 @@
"tslib": "^2.0.0"
}
},
"@dnd-kit/modifiers": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-8.0.0.tgz",
"integrity": "sha512-oPZ0JoKtVSK9hVHSBDKG1oCLgnZbpxuH/SMnyDtXkNhn9+SF1+98DlWILFYxIT8faOS/GgfhlNFREmym6oqrEg==",
"requires": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
}
},
"@dnd-kit/sortable": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-9.0.0.tgz",
@ -9293,6 +9695,14 @@
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"requires": {}
},
"@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.0"
}
}
}
},
@ -9597,6 +10007,25 @@
"@radix-ui/react-use-callback-ref": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.6.0"
},
"dependencies": {
"@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"requires": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
}
},
"@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.0"
}
}
}
},
"@radix-ui/react-popover": {
@ -9619,6 +10048,25 @@
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.6.0"
},
"dependencies": {
"@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"requires": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
}
},
"@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.0"
}
}
}
},
"@radix-ui/react-popper": {
@ -9647,12 +10095,22 @@
}
},
"@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
"requires": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"dependencies": {
"@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-presence": {
@ -9670,6 +10128,16 @@
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
"requires": {
"@radix-ui/react-slot": "1.1.0"
},
"dependencies": {
"@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.0"
}
}
}
},
"@radix-ui/react-progress": {
@ -9689,6 +10157,80 @@
}
}
},
"@radix-ui/react-radio-group": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz",
"integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==",
"requires": {
"@radix-ui/primitive": "1.1.1",
"@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-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-roving-focus": "1.1.2",
"@radix-ui/react-use-controllable-state": "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-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
}
},
"@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-roving-focus": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
"integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==",
"requires": {
"@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-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
}
}
}
},
"@radix-ui/react-roving-focus": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",
@ -9739,6 +10281,25 @@
"@radix-ui/react-visually-hidden": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.6.0"
},
"dependencies": {
"@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"requires": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
}
},
"@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.0"
}
}
}
},
"@radix-ui/react-separator": {
@ -9820,23 +10381,23 @@
"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",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"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.0"
"@radix-ui/react-compose-refs": "1.1.1"
},
"dependencies": {
"@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-switch": {
@ -9914,6 +10475,17 @@
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.0"
},
"dependencies": {
"@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"requires": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
}
}
}
},
"@radix-ui/react-toggle": {
@ -9972,6 +10544,25 @@
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.0"
},
"dependencies": {
"@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"requires": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
}
},
"@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.0"
}
}
}
},
"@radix-ui/react-use-callback-ref": {
@ -11024,9 +11615,9 @@
}
},
"date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="
},
"debug": {
"version": "4.3.7",
@ -12890,6 +13481,12 @@
"loose-envify": "^1.1.0"
}
},
"react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"requires": {}
},
"react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View file

@ -17,7 +17,6 @@
},
"dependencies": {
"@dnd-kit/core": "^6.2.0",
"@dnd-kit/modifiers": "^8.0.0",
"@dnd-kit/sortable": "^9.0.0",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.2",
@ -28,11 +27,13 @@
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-portal": "^1.1.4",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.3",
"@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-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
@ -43,7 +44,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.15.0",
"geist": "^1.3.0",
"input-otp": "^1.4.1",
@ -56,6 +57,7 @@
"pretty-bytes": "^6.1.1",
"qr-code-styling": "^1.9.1",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"react-use-websocket": "^4.11.1",

View file

@ -1,215 +1,559 @@
import * as React from "react";
import {
type DownloadStatus,
type FileFilter,
type TransferStatus,
type ChangeEvent,
type CSSProperties,
useEffect,
useState,
} from "react";
import { format } from "date-fns";
import {
ArrowDownNarrowWide,
ArrowUpNarrowWide,
Calendar as CalendarRange,
Filter,
X,
} from "lucide-react";
import type {
DownloadStatus,
FileFilter,
FileType,
TransferStatus,
} from "@/lib/types";
import { Input } from "@/components/ui/input";
import { Button } from "./ui/button";
import {
Drawer,
DrawerDescription,
DrawerFooter,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import { Drawer as DrawerPrimitive } from "vaul";
import {
Select,
SelectContent,
SelectGroup,
SelectLabel,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import TableColumnFilter, {
type Column,
} from "@/components/table-column-filter";
import { type RowHeight } from "@/components/table-row-height-switch";
import FileTypeFilter from "@/components/file-type-filter";
import dynamic from "next/dynamic";
import { useDebouncedCallback } from "use-debounce";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { Check, X } from "lucide-react";
import { Button } from "./ui/button";
import { DOWNLOAD_STATUS, TRANSFER_STATUS } from "@/components/file-status";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Label } from "@/components/ui/label";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { RangeSlider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import FileTypeFilter from "@/components/file-type-filter";
import FileStatusFilter from "@/components/file-status-filter";
import { Switch } from "@/components/ui/switch";
import useIsMobile from "@/hooks/use-is-mobile";
const SearchFilter = ({
search,
onChange,
}: {
search: string;
onChange: (search: string) => void;
}) => {
const [localSearch, setLocalSearch] = useState(search);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setLocalSearch(e.target.value);
onChange(e.target.value);
};
return (
<div className="space-y-2">
<Label>Keyword</Label>
<div className="relative">
<Input
placeholder="Search with name or caption"
value={localSearch}
onChange={handleChange}
/>
{search && (
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 h-6 w-6 -translate-y-1/2 rounded-full text-gray-500 transition-all duration-200 hover:scale-110 hover:bg-gray-100 hover:text-gray-800"
onClick={() => setLocalSearch("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
};
interface DateFilterProps {
dateType: "sent" | "downloaded" | undefined;
dateRange: [string, string] | undefined;
onChange: (type: "sent" | "downloaded", range: [string, string]) => void;
}
const DateFilter = ({ dateType, dateRange, onChange }: DateFilterProps) => {
const [open, setOpen] = useState(false);
const isMobile = useIsMobile();
const [localType, setLocalType] = useState<"sent" | "downloaded">(
dateType ?? "sent",
);
const [localRange, setLocalRange] = useState<
[Date | undefined, Date | undefined]
>([
dateRange?.[0] ? new Date(dateRange[0]) : undefined,
dateRange?.[1] ? new Date(dateRange[1]) : undefined,
]);
const handleTypeChange = (type: "sent" | "downloaded") => {
setLocalType(type);
};
const handleRangeSelect = (range?: {
from: Date | undefined;
to?: Date | undefined;
}) => {
if (!range) return;
setLocalRange([range.from, range.to]);
if (range.from && range.to) {
onChange(localType, [
format(range.from, "yyyy-MM-dd"),
format(range.to, "yyyy-MM-dd"),
]);
}
};
const getDisplayText = () => {
if (!dateRange?.[0] && !dateRange?.[1]) return "Select date range";
if (dateRange[0] && dateRange[1]) {
return `${format(new Date(dateRange[0]), "LLL dd, y")} - ${format(new Date(dateRange[1]), "LLL dd, y")}`;
}
return "Date range selected";
};
return (
<div className="space-y-2">
<Label>Date Filter</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<CalendarRange className="mr-2 h-4 w-4" />
<span className="flex-1">{getDisplayText()}</span>
<span className="ml-2 rounded bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600">
{localType === "downloaded" ? "Download" : "Sent"}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-4"
side={isMobile ? undefined : "right"}
modal={true}
>
<div className="space-y-4">
<div className="flex gap-2">
<Button
size="sm"
variant={localType === "sent" ? "default" : "outline"}
onClick={() => handleTypeChange("sent")}
className="flex-1"
>
Sent Date
</Button>
<Button
size="sm"
variant={localType === "downloaded" ? "default" : "outline"}
onClick={() => handleTypeChange("downloaded")}
className="flex-1"
>
Downloaded
</Button>
</div>
<div className="rounded-md border p-2">
<Calendar
mode="range"
selected={{
from: localRange[0],
to: localRange[1],
}}
onSelect={handleRangeSelect}
numberOfMonths={2}
defaultMonth={localRange[0] ?? new Date()}
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
};
interface SizeFilterProps {
sizeRange: [number, number] | undefined;
sizeUnit: "KB" | "MB" | "GB" | undefined;
onChange: (range: [number, number], unit: "KB" | "MB" | "GB") => void;
}
const SizeFilter = ({ sizeRange, onChange }: SizeFilterProps) => {
const defaultRange: [number, number] = [0, 1000];
const [localRange, setLocalRange] = useState<[number, number]>(
sizeRange ?? defaultRange,
);
const [localUnit, setLocalUnit] = useState<"KB" | "MB" | "GB">("MB");
const handleChange = (newValue: number[]) => {
const range: [number, number] = [newValue[0]!, newValue[1]!];
setLocalRange(range);
onChange(range, localUnit);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>File Size Range</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
{localUnit}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="center" modal={true}>
<div className="flex flex-col gap-2">
<Button
size="sm"
variant={localUnit === "KB" ? "default" : "outline"}
onClick={() => setLocalUnit("KB")}
>
KB
</Button>
<Button
size="sm"
variant={localUnit === "MB" ? "default" : "outline"}
onClick={() => setLocalUnit("MB")}
>
MB
</Button>
<Button
size="sm"
variant={localUnit === "GB" ? "default" : "outline"}
onClick={() => setLocalUnit("GB")}
>
GB
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div
className="px-2 pt-2"
onPointerDown={(e) => {
e.stopPropagation();
}}
>
<RangeSlider
value={localRange}
min={0}
max={1000}
step={1}
minStepsBetweenThumbs={1}
className="w-full"
onValueChange={handleChange}
/>
</div>
<div className="flex justify-between text-sm">
<span className="text-zinc-500">
{localRange[0]} {localUnit}
</span>
<span className="text-zinc-500">
{localRange[1]} {localUnit}
</span>
</div>
</div>
);
};
interface SortFilterProps {
sort: "date" | "completion_date" | "size" | undefined;
order: "asc" | "desc" | undefined;
onChange: (
sort: "date" | "completion_date" | "size",
order: "asc" | "desc",
) => void;
}
const SortFilter = ({ sort, order, onChange }: SortFilterProps) => {
const currentSort = sort ?? "date";
const currentOrder = order ?? "desc";
const sortOptions = [
{ value: "date", label: "Sent Date" },
{ value: "completion_date", label: "Downloaded Date" },
{ value: "size", label: "File Size" },
] as const;
return (
<div className="space-y-2">
<Label>Sort By</Label>
<div className="flex gap-2">
<Select
value={currentSort}
onValueChange={(newSort: typeof currentSort) =>
onChange(newSort, currentOrder)
}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() =>
onChange(currentSort, currentOrder === "asc" ? "desc" : "asc")
}
className={cn("h-9 w-9")}
>
{currentOrder === "asc" ? (
<ArrowUpNarrowWide className="h-4 w-4" />
) : (
<ArrowDownNarrowWide className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
};
interface FileFiltersProps {
telegramId: string;
chatId: string;
filters: FileFilter;
onFiltersChange: (filters: FileFilter) => void;
columns?: Column[];
onColumnConfigChange?: (config: Column[]) => void;
rowHeight?: RowHeight;
setRowHeight?: (e: RowHeight) => void;
clearFilters: () => void;
}
const TableRowHeightSwitch = dynamic(
() =>
import("@/components/table-row-height-switch").then(
(mod) => mod.TableRowHeightSwitch,
),
{ ssr: false },
);
const statusOptions = {
downloadStatus: {
text: "Download Status",
status: DOWNLOAD_STATUS,
},
transferStatus: {
text: "Transfer Status",
status: TRANSFER_STATUS,
},
};
export function FileFilters({
export default function FileFilters({
telegramId,
chatId,
filters,
onFiltersChange,
columns = [],
onColumnConfigChange = () => void 0,
rowHeight = "m",
setRowHeight = () => void 0,
clearFilters,
}: FileFiltersProps) {
const [localFilters, setLocalFilters] = useState<FileFilter>(filters);
const isMobile = useIsMobile();
const [search, setSearch] = useState(filters.search);
const handleSearchChange = useDebouncedCallback((search: string) => {
onFiltersChange({ ...filters, search });
}, 500);
const handleStatusSelect = (value: string) => {
const [group, item] = value.split("||");
if (group === "downloadStatus") {
onFiltersChange({
...filters,
downloadStatus:
item === filters.downloadStatus
? undefined
: (item as DownloadStatus),
});
} else if (group === "transferStatus") {
onFiltersChange({
...filters,
transferStatus:
item === filters.transferStatus
? undefined
: (item as TransferStatus),
});
}
};
const statusDisplayValue = useMemo(() => {
if (!filters.downloadStatus && !filters.transferStatus) {
return "Filter Status";
}
const downloadStatus =
filters.downloadStatus && DOWNLOAD_STATUS[filters.downloadStatus];
const transferStatus =
filters.transferStatus && TRANSFER_STATUS[filters.transferStatus];
return (
<div className="flex items-center space-x-2">
{downloadStatus && (
<div className="flex items-center space-x-1 rounded bg-gray-100 p-1 dark:bg-gray-800">
<span className="text-xs">Download</span>
<downloadStatus.icon className="h-3 w-3" />
</div>
)}
{transferStatus && (
<div className="flex items-center space-x-1 rounded bg-gray-100 p-1 dark:bg-gray-800">
<span className="text-xs">Transfer</span>
<transferStatus.icon className="h-3 w-3" />
</div>
)}
</div>
);
}, [filters.downloadStatus, filters.transferStatus]);
const [open, setOpen] = useState(false);
useEffect(() => {
handleSearchChange(search);
}, [search, handleSearchChange]);
setLocalFilters(filters);
}, [filters]);
const filterCount = Object.values(filters).filter((value) => {
if (typeof value === "string") return value !== "";
if (typeof value === "boolean") return value;
if (Array.isArray(value)) return value.length > 0;
return false;
}).length;
const handleSearchChange = (search: string) => {
setLocalFilters((prev) => ({ ...prev, search }));
};
const handleTypeChange = (type: FileType | "all") => {
setLocalFilters((prev) => ({ ...prev, type }));
};
const handleStatusChange = (
downloadStatus?: DownloadStatus,
transferStatus?: TransferStatus,
) => {
setLocalFilters((prev) => ({
...prev,
downloadStatus,
transferStatus,
}));
};
const handleDateChange = (
dateType: "sent" | "downloaded",
dateRange: [string, string],
) => {
setLocalFilters((prev) => ({ ...prev, dateType, dateRange }));
};
const handleSizeChange = (
sizeRange: [number, number],
sizeUnit: "KB" | "MB" | "GB",
) => {
setLocalFilters((prev) => ({ ...prev, sizeRange, sizeUnit }));
};
const handleSortChange = (
sort: "date" | "completion_date" | "size",
order: "asc" | "desc",
) => {
setLocalFilters((prev) => ({ ...prev, sort, order }));
};
const handleApply = () => {
onFiltersChange(localFilters);
setOpen(false);
};
const handleClear = () => {
clearFilters();
setOpen(false);
};
return (
<div className="mb-6 flex flex-col flex-wrap gap-2 justify-between md:flex-row">
<div className="grid grid-cols-3 gap-4 md:flex md:flex-row">
<div className="relative col-span-3">
<Input
placeholder="Search files..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="relative md:w-[300px]"
/>
{search && (
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 h-6 w-6 -translate-y-1/2 rounded-full text-gray-500 transition-all duration-200 hover:scale-110 hover:bg-gray-100 hover:text-gray-800"
onClick={() => setSearch("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<FileTypeFilter
telegramId={telegramId}
chatId={chatId}
type={filters.type}
onTypeChange={(type) => onFiltersChange({ ...filters, type })}
/>
<Select
value={`${filters.downloadStatus ?? ""}||${filters.transferStatus ?? ""}`}
onValueChange={(value) => handleStatusSelect(value)}
<Drawer
open={open}
onOpenChange={setOpen}
direction={isMobile ? "bottom" : "left"}
>
<DrawerTrigger asChild>
<Button
variant="outline"
className={cn("relative gap-2", isMobile && "z-50 w-9")}
>
<SelectTrigger className="col-span-2 w-full md:w-[210px]">
<SelectValue placeholder="Filter Status">
{statusDisplayValue}
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(statusOptions).map(([groupKey, group]) => (
<SelectGroup key={groupKey}>
<SelectLabel>{group.text}</SelectLabel>
{Object.entries(group.status).map(([statusKey, item]) => (
<SelectPrimitive.Item
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
!isMobile && "focus:bg-accent focus:text-accent-foreground"
)}
>
{filters[groupKey as keyof FileFilter] === statusKey && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check className="h-4 w-4" />
</span>
)}
<SelectPrimitive.ItemText>
<div className="flex items-center">
{item.icon && <item.icon className="mr-2 h-4 w-4" />}
{item.text}
</div>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
<Filter className="h-5 w-5" />
{!isMobile && "Filters"}
{filterCount > 0 && (
<span className="absolute left-0 top-0 -ml-1 -mt-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs text-white">
{filterCount}
</span>
)}
</Button>
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay className="bg-black/30 dark:bg-black/50" />
<DrawerPrimitive.Content
className={cn(
isMobile
? "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background"
: "fixed bottom-2 left-2 top-2 z-50 flex w-[380px] outline-none",
)}
style={
isMobile
? {}
: ({ "--initial-transform": "calc(100% + 8px)" } as CSSProperties)
}
>
<div className="flex h-full w-full grow flex-col rounded-[16px] bg-white shadow-lg dark:bg-zinc-900">
<div className="flex-1 p-6">
<DrawerTitle>
<div className="flex items-center justify-between">
<span className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
Filters
</span>
<div className="flex items-center space-x-2">
<Label
htmlFor="offline"
className="cursor-pointer text-zinc-500"
>
Offline
</Label>
<Switch
id="offline"
checked={localFilters.offline}
onCheckedChange={(checked) => {
setLocalFilters((prev) => ({
...prev,
offline: checked,
}));
}}
/>
</div>
</div>
</DrawerTitle>
<DrawerDescription className="mb-3">
Default search by Telegram Client, you can choose offline to
search by local database.
</DrawerDescription>
{!isMobile && (
<div className="hidden gap-4 md:flex">
<TableColumnFilter
columns={columns}
onColumnConfigChange={onColumnConfigChange}
/>
<TableRowHeightSwitch
rowHeight={rowHeight}
setRowHeightAction={setRowHeight}
/>
</div>
)}
</div>
<div className="space-y-4 overflow-y-auto px-0.5">
<SearchFilter
search={localFilters.search}
onChange={handleSearchChange}
/>
<FileTypeFilter
offline={localFilters.offline}
telegramId={telegramId}
chatId={chatId}
type={filters.type}
onChange={handleTypeChange}
/>
{!localFilters.offline && (
<div className="flex items-center justify-between rounded-md border bg-zinc-100 px-2 py-3 dark:bg-zinc-800">
<Label htmlFor="notDownload">Filter Not Download</Label>
<Switch
id="notDownload"
checked={localFilters.downloadStatus === "idle"}
onCheckedChange={(checked) => {
setLocalFilters((prev) => ({
...prev,
downloadStatus: checked ? "idle" : undefined,
}));
}}
aria-label="Not Download"
/>
</div>
)}
{localFilters.offline && (
<>
<FileStatusFilter
downloadStatus={localFilters.downloadStatus}
transferStatus={localFilters.transferStatus}
onChange={handleStatusChange}
/>
<DateFilter
dateType={localFilters.dateType}
dateRange={localFilters.dateRange}
onChange={handleDateChange}
/>
<SizeFilter
sizeRange={localFilters.sizeRange}
sizeUnit={localFilters.sizeUnit}
onChange={handleSizeChange}
/>
<SortFilter
sort={localFilters.sort}
order={localFilters.order}
onChange={handleSortChange}
/>
</>
)}
</div>
</div>
<DrawerFooter>
<Button onClick={handleApply}>Apply Filters</Button>
<Button variant="outline" onClick={handleClear}>
Clear Filters
</Button>
</DrawerFooter>
</div>
</DrawerPrimitive.Content>
</DrawerPortal>
</Drawer>
);
}

View file

@ -0,0 +1,144 @@
import {
Select,
SelectContent,
SelectGroup,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
import type { DownloadStatus, TransferStatus } from "@/lib/types";
import { Check } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { DOWNLOAD_STATUS, TRANSFER_STATUS } from "@/components/file-status";
import useIsMobile from "@/hooks/use-is-mobile";
import { Label } from "@/components/ui/label";
const statusOptions = {
downloadStatus: {
text: "Download Status",
status: DOWNLOAD_STATUS,
},
transferStatus: {
text: "Transfer Status",
status: TRANSFER_STATUS,
},
};
interface FileStatusFilterProps {
downloadStatus: DownloadStatus | undefined;
transferStatus: TransferStatus | undefined;
onChange: (
downloadStatus?: DownloadStatus,
transferStatus?: TransferStatus,
) => void;
}
export default function FileStatusFilter({
downloadStatus,
transferStatus,
onChange,
}: FileStatusFilterProps) {
const isMobile = useIsMobile();
const [localDownloadStatus, setLocalDownloadStatus] = React.useState<
DownloadStatus | undefined
>(downloadStatus);
const [localTransferStatus, setLocalTransferStatus] = React.useState<
TransferStatus | undefined
>(transferStatus);
const handleStatusSelect = (value: string) => {
const [group, item] = value.split("||");
let downloadStatus = localDownloadStatus;
let transferStatus = localTransferStatus;
if (group === "downloadStatus") {
const updatedDownloadStatus =
localDownloadStatus === item ? undefined : (item as DownloadStatus);
setLocalDownloadStatus(updatedDownloadStatus);
downloadStatus = updatedDownloadStatus;
} else if (group === "transferStatus") {
const updatedTransferStatus =
localTransferStatus === item ? undefined : (item as TransferStatus);
setLocalTransferStatus(updatedTransferStatus);
transferStatus = updatedTransferStatus;
}
onChange(downloadStatus, transferStatus);
};
const statusDisplayValue = useMemo(() => {
if (!localDownloadStatus && !localTransferStatus) {
return "Filter Status";
}
const downloadStatus =
localDownloadStatus && DOWNLOAD_STATUS[localDownloadStatus];
const transferStatus =
localTransferStatus && TRANSFER_STATUS[localTransferStatus];
return (
<div className="flex items-center space-x-2">
{downloadStatus && (
<div className="flex items-center space-x-1 rounded bg-gray-100 p-1 dark:bg-gray-800">
<span className="text-xs">Download</span>
<downloadStatus.icon className="h-3 w-3" />
</div>
)}
{transferStatus && (
<div className="flex items-center space-x-1 rounded bg-gray-100 p-1 dark:bg-gray-800">
<span className="text-xs">Transfer</span>
<transferStatus.icon className="h-3 w-3" />
</div>
)}
</div>
);
}, [localDownloadStatus, localTransferStatus]);
return (
<div className="space-y-2">
<Label>Status</Label>
<Select
value={`${localDownloadStatus ?? ""}||${localTransferStatus ?? ""}`}
onValueChange={(value) => handleStatusSelect(value)}
>
<SelectTrigger>
<SelectValue placeholder="Filter Status">
{statusDisplayValue}
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(statusOptions).map(([groupKey, group]) => (
<SelectGroup key={groupKey}>
<SelectLabel>{group.text}</SelectLabel>
{Object.entries(group.status).map(([statusKey, item]) => (
<SelectPrimitive.Item
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
!isMobile && "focus:bg-accent focus:text-accent-foreground",
)}
>
{(groupKey === "downloadStatus"
? localDownloadStatus
: localTransferStatus) === statusKey && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check className="h-4 w-4" />
</span>
)}
<SelectPrimitive.ItemText>
<div className="flex items-center">
{item.icon && <item.icon className="mr-2 h-4 w-4" />}
{item.text}
</div>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
);
}

View file

@ -4,12 +4,14 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Download, LoaderCircle, LoaderPinwheel } from "lucide-react";
import { useFiles } from "@/hooks/use-files";
import { FileFilters } from "@/components/file-filters";
import {
getRowHeightPX,
TableRowHeightSwitch,
useRowHeightLocalStorage,
} from "@/components/table-row-height-switch";
import { type Column } from "@/components/table-column-filter";
import TableColumnFilter, {
type Column,
} from "@/components/table-column-filter";
import { cn } from "@/lib/utils";
import FileNotFount from "@/components/file-not-found";
import useSWRMutation from "swr/mutation";
@ -18,6 +20,8 @@ import FileRow from "@/components/file-row";
import { useVirtualizer } from "@tanstack/react-virtual";
import { type TelegramFile } from "@/lib/types";
import FileViewer from "@/components/file-viewer";
import FileFilters from "./file-filters";
import { Badge } from "@/components/ui/badge";
const COLUMNS: Column[] = [
{
@ -62,9 +66,17 @@ export function FileTable({ accountId, chatId }: FileTableProps) {
"m",
);
const useFilesProps = useFiles(accountId, chatId);
const { filters, handleFilterChange, isLoading, files, handleLoadMore } =
useFilesProps;
const [currentViewFile, setCurrentViewFile] = useState<TelegramFile | undefined>();
const {
filters,
handleFilterChange,
clearFilters,
isLoading,
files,
handleLoadMore,
} = useFilesProps;
const [currentViewFile, setCurrentViewFile] = useState<
TelegramFile | undefined
>();
const [viewerOpen, setViewerOpen] = useState(false);
const {
trigger: startDownloadMultiple,
@ -185,16 +197,30 @@ export function FileTable({ accountId, chatId }: FileTableProps) {
return (
<>
<FileFilters
telegramId={accountId}
chatId={chatId}
filters={filters}
onFiltersChange={handleFilterChange}
columns={columns}
onColumnConfigChange={setColumns}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
<div className="mb-6 flex flex-col flex-wrap justify-between gap-2 md:flex-row">
<div className="flex items-center gap-3">
<Badge variant="outline" className="flex h-full bg-accent">
{filters.type.charAt(0).toUpperCase() + filters.type.slice(1)}
</Badge>
<FileFilters
telegramId={accountId}
chatId={chatId}
filters={filters}
onFiltersChange={handleFilterChange}
clearFilters={clearFilters}
/>
</div>
<div className="hidden gap-4 md:flex">
<TableColumnFilter
columns={columns}
onColumnConfigChange={setColumns}
/>
<TableRowHeightSwitch
rowHeight={rowHeight}
setRowHeightAction={setRowHeight}
/>
</div>
</div>
{currentViewFile && (
<FileViewer
open={viewerOpen}

View file

@ -8,29 +8,41 @@ import {
import type { FileType } from "@/lib/types";
import useSWR from "swr";
import { Ellipsis } from "lucide-react";
import { Label } from "@/components/ui/label";
import * as React from "react";
interface FileTypeFilterProps {
offline: boolean;
telegramId: string;
chatId: string;
type: FileType;
onTypeChange: (type: FileType) => void;
type: FileType | "all";
onChange: (type: FileType | "all") => void;
}
export default function FileTypeFilter({
offline,
telegramId,
chatId,
type,
onTypeChange,
onChange,
}: FileTypeFilterProps) {
const [localType, setLocalType] = React.useState<FileType | "all">(type);
const { data: counts, isLoading } = useSWR<Record<FileType, number>>(
`/telegram/${telegramId}/chat/${chatId}/files/count`,
);
const handleTypeChange = (value: FileType | "all") => {
setLocalType(value);
onChange(value);
};
const FileTypeSelectItem = ({ value }: { value: FileType }) => {
return (
<SelectItem value={value}>
<div className="flex w-20 items-center justify-between">
<span>{value.charAt(0).toUpperCase() + value.slice(1)}</span>
<div className="flex items-center gap-5">
<span className="w-10">
{value.charAt(0).toUpperCase() + value.slice(1)}
</span>
{isLoading ? (
<Ellipsis className="h-4 w-4 animate-pulse" />
) : (
@ -44,20 +56,21 @@ export default function FileTypeFilter({
};
return (
<Select
value={type}
onValueChange={(value) => onTypeChange(value as FileType)}
>
<SelectTrigger className="w-full md:w-[150px]">
<SelectValue placeholder="File type" />
</SelectTrigger>
<SelectContent>
<FileTypeSelectItem value="media" />
<FileTypeSelectItem value="photo" />
<FileTypeSelectItem value="video" />
<FileTypeSelectItem value="audio" />
<FileTypeSelectItem value="file" />
</SelectContent>
</Select>
<div className="space-y-2">
<Label>Type</Label>
<Select value={localType} onValueChange={handleTypeChange}>
<SelectTrigger>
<SelectValue placeholder="File type" />
</SelectTrigger>
<SelectContent>
{offline && <SelectItem value="all">All files</SelectItem>}
<FileTypeSelectItem value="media" />
<FileTypeSelectItem value="photo" />
<FileTypeSelectItem value="video" />
<FileTypeSelectItem value="audio" />
<FileTypeSelectItem value="file" />
</SelectContent>
</Select>
</div>
);
}

View file

@ -1,4 +1,3 @@
import { FileFilters } from "@/components/file-filters";
import { LoaderPinwheel } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useFiles } from "@/hooks/use-files";
@ -8,6 +7,8 @@ import { cn } from "@/lib/utils";
import FileDrawer from "@/components/mobile/file-drawer";
import type { TelegramFile } from "@/lib/types";
import { isEqual } from "lodash";
import FileFilters from "@/components/file-filters";
import DraggableElement from "@/components/ui/draggable-element";
interface FileListProps {
accountId: string;
@ -24,6 +25,7 @@ export default function FileList({ accountId, chatId }: FileListProps) {
const {
filters,
handleFilterChange,
clearFilters,
isLoading,
files,
hasMore,
@ -67,12 +69,15 @@ export default function FileList({ accountId, chatId }: FileListProps) {
return (
<div className="space-y-4">
<FileFilters
telegramId={accountId}
chatId={chatId}
filters={filters}
onFiltersChange={handleFilterChange}
/>
<DraggableElement>
<FileFilters
telegramId={accountId}
chatId={chatId}
filters={filters}
onFiltersChange={handleFilterChange}
clearFilters={clearFilters}
/>
</DraggableElement>
{currentViewFile && (
<FileDrawer
open={isDrawerOpen}

View file

@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100",
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View file

@ -0,0 +1,164 @@
import React, { useRef, useState } from "react";
import { DndContext, type DragEndEvent, useDraggable } from "@dnd-kit/core";
import { Portal } from "@radix-ui/react-portal";
import { cn } from "@/lib/utils";
interface DraggableElementProps {
children: React.ReactNode;
className?: string;
onTap?: () => void;
longPressTime?: number; // 长按触发拖拽的时间 (ms)
}
const DraggableContent = ({
children,
className = "",
style,
onTap,
longPressTime = 300,
}: DraggableElementProps & { style: React.CSSProperties }) => {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: "draggable-element",
});
const touchStartTime = useRef<number>(0);
const touchStartPos = useRef<{ x: number; y: number } | null>(null);
const longPressTimer = useRef<NodeJS.Timeout>();
const [isLongPress, setIsLongPress] = useState(false);
const transformStyle = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: {};
const handleTouchStart = (e: React.TouchEvent) => {
touchStartTime.current = Date.now();
touchStartPos.current = {
x: e.touches[0]!.clientX,
y: e.touches[0]!.clientY,
};
longPressTimer.current = setTimeout(() => {
setIsLongPress(true);
}, longPressTime);
};
const handleTouchEnd = (e: React.TouchEvent) => {
clearTimeout(longPressTimer.current);
if (touchStartPos.current) {
const touchEndX = e.changedTouches[0]!.clientX;
const touchEndY = e.changedTouches[0]!.clientY;
const deltaX = Math.abs(touchEndX - touchStartPos.current.x);
const deltaY = Math.abs(touchEndY - touchStartPos.current.y);
const touchDuration = Date.now() - touchStartTime.current;
// 如果移动距离小于阈值且不是长按,则视为点击
if (
deltaX < 5 &&
deltaY < 5 &&
touchDuration < longPressTime &&
!isLongPress
) {
onTap?.();
}
}
setIsLongPress(false);
touchStartPos.current = null;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (touchStartPos.current) {
const deltaX = Math.abs(e.touches[0]!.clientX - touchStartPos.current.x);
const deltaY = Math.abs(e.touches[0]!.clientY - touchStartPos.current.y);
// 如果移动距离超过阈值,取消长按计时
if (deltaX > 5 || deltaY > 5) {
clearTimeout(longPressTimer.current);
}
}
};
const preventContextMenu = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div
ref={setNodeRef}
{...(isLongPress ? listeners : {})}
{...attributes}
className={cn(
`fixed touch-none select-none`,
isDragging && "opacity-80",
isLongPress && "cursor-grabbing",
className,
)}
style={{
...style,
...transformStyle,
touchAction: "none",
WebkitUserSelect: "none", // 禁用文本选择
WebkitTouchCallout: "none", // 禁用触摸回调
userSelect: "none",
}}
onContextMenu={preventContextMenu}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
>
<div
className={`relative ${isLongPress ? "scale-105 transition-transform" : ""}`}
>
{children}
</div>
{isLongPress && (
<div className="absolute inset-0 animate-pulse rounded-lg bg-black/5" />
)}
</div>
);
};
const DraggableElement = (props: DraggableElementProps) => {
const [position, setPosition] = useState({
x: window.innerWidth - 60,
y: window.innerHeight - 60,
});
const handleDragEnd = (event: DragEndEvent) => {
const { delta } = event;
setPosition((prev) => {
const newX = Math.max(
0,
Math.min(prev.x + delta.x, window.innerWidth - 40),
);
const newY = Math.max(
0,
Math.min(prev.y + delta.y, window.innerHeight - 40),
);
return { x: newX, y: newY };
});
};
const style: React.CSSProperties = {
top: position.y,
left: position.x,
};
return (
<Portal>
<DndContext onDragEnd={handleDragEnd}>
<div id="draggable-content">
<DraggableContent {...props} style={style} />
</div>
</DndContext>
</Portal>
);
};
export default DraggableElement;

View file

@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View file

@ -25,4 +25,25 @@ const Slider = React.forwardRef<
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
const RangeSlider = 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="cursor-pointer 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.Thumb className="cursor-pointer 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>
));
RangeSlider.displayName = SliderPrimitive.Root.displayName;
export { Slider, RangeSlider };

View file

@ -16,6 +16,7 @@ const DEFAULT_FILTERS: FileFilter = {
type: "media",
downloadStatus: undefined,
transferStatus: undefined,
offline: false,
};
type FileResponse = {
@ -51,6 +52,13 @@ export function useFiles(accountId: string, chatId: string) {
...(filters.type && { type: filters.type }),
...(filters.downloadStatus && { downloadStatus: filters.downloadStatus }),
...(filters.transferStatus && { transferStatus: filters.transferStatus }),
...(filters.offline && { offline: "true" }),
...(filters.dateType && { dateType: filters.dateType }),
...(filters.dateRange && { dateRange: filters.dateRange.join(",") }),
...(filters.sizeRange && { sizeRange: filters.sizeRange.join(",") }),
...(filters.sizeUnit && { sizeUnit: filters.sizeUnit }),
...(filters.sort && { sort: filters.sort }),
...(filters.order && { order: filters.order }),
});
if (page === 0) {
@ -181,10 +189,11 @@ export function useFiles(accountId: string, chatId: string) {
const handleFilterChange = async (newFilters: FileFilter) => {
if (
newFilters.search === filters.search &&
newFilters.type === filters.type &&
newFilters.downloadStatus === filters.downloadStatus &&
newFilters.transferStatus === filters.transferStatus
Object.keys(newFilters).every(
(key) =>
newFilters[key as keyof FileFilter] ===
filters[key as keyof FileFilter],
)
) {
return;
}
@ -197,6 +206,7 @@ export function useFiles(accountId: string, chatId: string) {
filters,
isLoading: debounceLoading,
handleFilterChange,
clearFilters,
handleLoadMore,
hasMore,
};

View file

@ -97,9 +97,16 @@ export type TDFile = {
export type FileFilter = {
search: string;
type: FileType;
type: FileType | "all";
downloadStatus?: DownloadStatus;
transferStatus?: TransferStatus;
offline: boolean;
dateType?: "sent" | "downloaded";
dateRange?: [string, string];
sizeRange?: [number, number];
sizeUnit?: "KB" | "MB" | "GB";
sort?: "date" | "completion_date" | "size";
order?: "asc" | "desc";
};
export type TelegramApiResult = {