diff --git a/api/src/main/java/telegram/files/MessyUtils.java b/api/src/main/java/telegram/files/MessyUtils.java index 3a7eb83..16dcc41 100644 --- a/api/src/main/java/telegram/files/MessyUtils.java +++ b/api/src/main/java/telegram/files/MessyUtils.java @@ -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); + }; + } } diff --git a/api/src/main/java/telegram/files/TelegramVerticle.java b/api/src/main/java/telegram/files/TelegramVerticle.java index bb6f3e8..2b21fd2 100644 --- a/api/src/main/java/telegram/files/TelegramVerticle.java +++ b/api/src/main/java/telegram/files/TelegramVerticle.java @@ -175,8 +175,8 @@ public class TelegramVerticle extends AbstractVerticle { } public Future getChatFiles(long chatId, Map 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(); diff --git a/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java b/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java index 558c555..b42ebd9 100644 --- a/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java +++ b/api/src/main/java/telegram/files/repository/impl/FileRepositoryImpl.java @@ -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, Long, Long>> getFiles(long chatId, Map 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 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()))) diff --git a/web/package-lock.json b/web/package-lock.json index 7c862db..b8d6b00 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 61bbfe5..9795fd1 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/file-filters.tsx b/web/src/components/file-filters.tsx index 704deef..68384b2 100644 --- a/web/src/components/file-filters.tsx +++ b/web/src/components/file-filters.tsx @@ -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) => { + setLocalSearch(e.target.value); + onChange(e.target.value); + }; + + return ( +
+ +
+ + {search && ( + + )} +
+
+ ); +}; + +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 ( +
+ + + + + + +
+
+ + +
+
+ +
+
+
+
+
+ ); +}; + +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 ( +
+
+ + + + + + +
+ + + +
+
+
+
+
{ + e.stopPropagation(); + }} + > + +
+
+ + {localRange[0]} {localUnit} + + + {localRange[1]} {localUnit} + +
+
+ ); +}; + +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 ( +
+ +
+ + +
+
+ ); +}; + 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(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 ( -
- {downloadStatus && ( -
- Download - -
- )} - {transferStatus && ( -
- Transfer - -
- )} -
- ); - }, [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 ( -
-
-
- setSearch(e.target.value)} - className="relative md:w-[300px]" - /> - {search && ( - - )} -
- - onFiltersChange({ ...filters, type })} - /> - - -
+ + {!isMobile && "Filters"} + {filterCount > 0 && ( + + {filterCount} + + )} + + + + + +
+
+ +
+ + Filters + +
+ + { + setLocalFilters((prev) => ({ + ...prev, + offline: checked, + })); + }} + /> +
+
+
+ + Default search by Telegram Client, you can choose offline to + search by local database. + - {!isMobile && ( -
- - -
- )} -
+
+ + + + + {!localFilters.offline && ( +
+ + { + setLocalFilters((prev) => ({ + ...prev, + downloadStatus: checked ? "idle" : undefined, + })); + }} + aria-label="Not Download" + /> +
+ )} + + {localFilters.offline && ( + <> + + + + + + + + + )} +
+
+ + + + + +
+ + + ); } diff --git a/web/src/components/file-status-filter.tsx b/web/src/components/file-status-filter.tsx new file mode 100644 index 0000000..5e55dc6 --- /dev/null +++ b/web/src/components/file-status-filter.tsx @@ -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 ( +
+ {downloadStatus && ( +
+ Download + +
+ )} + {transferStatus && ( +
+ Transfer + +
+ )} +
+ ); + }, [localDownloadStatus, localTransferStatus]); + + return ( +
+ + +
+ ); +} diff --git a/web/src/components/file-table.tsx b/web/src/components/file-table.tsx index 86478ae..5f92b5c 100644 --- a/web/src/components/file-table.tsx +++ b/web/src/components/file-table.tsx @@ -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(); + 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 ( <> - +
+
+ + {filters.type.charAt(0).toUpperCase() + filters.type.slice(1)} + + +
+
+ + +
+
{currentViewFile && ( 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(type); const { data: counts, isLoading } = useSWR>( `/telegram/${telegramId}/chat/${chatId}/files/count`, ); + const handleTypeChange = (value: FileType | "all") => { + setLocalType(value); + onChange(value); + }; + const FileTypeSelectItem = ({ value }: { value: FileType }) => { return ( -
- {value.charAt(0).toUpperCase() + value.slice(1)} +
+ + {value.charAt(0).toUpperCase() + value.slice(1)} + {isLoading ? ( ) : ( @@ -44,20 +56,21 @@ export default function FileTypeFilter({ }; return ( - +
+ + +
); } diff --git a/web/src/components/mobile/file-list.tsx b/web/src/components/mobile/file-list.tsx index 4fb8ae6..2b9888a 100644 --- a/web/src/components/mobile/file-list.tsx +++ b/web/src/components/mobile/file-list.tsx @@ -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 (
- + + + {currentViewFile && ( ; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .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 }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ); +} + +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/web/src/components/ui/draggable-element.tsx b/web/src/components/ui/draggable-element.tsx new file mode 100644 index 0000000..297d192 --- /dev/null +++ b/web/src/components/ui/draggable-element.tsx @@ -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(0); + const touchStartPos = useRef<{ x: number; y: number } | null>(null); + const longPressTimer = useRef(); + 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 ( +
+
+ {children} +
+ {isLongPress && ( +
+ )} +
+ ); +}; + +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 ( + + +
+ +
+
+
+ ); +}; + +export default DraggableElement; diff --git a/web/src/components/ui/radio-group.tsx b/web/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..8cf837d --- /dev/null +++ b/web/src/components/ui/radio-group.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/web/src/components/ui/slider.tsx b/web/src/components/ui/slider.tsx index 17a8cd8..6ed496b 100644 --- a/web/src/components/ui/slider.tsx +++ b/web/src/components/ui/slider.tsx @@ -25,4 +25,25 @@ const Slider = React.forwardRef< )); Slider.displayName = SliderPrimitive.Root.displayName; -export { Slider }; +const RangeSlider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + + +)); +RangeSlider.displayName = SliderPrimitive.Root.displayName; + +export { Slider, RangeSlider }; diff --git a/web/src/hooks/use-files.ts b/web/src/hooks/use-files.ts index 1bb9cc2..b70dbd9 100644 --- a/web/src/hooks/use-files.ts +++ b/web/src/hooks/use-files.ts @@ -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, }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index a65eabd..805719b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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 = {