From 9ce171911d794355733970086f53b70d0c6b82e3 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sat, 14 Mar 2026 07:15:04 +0700 Subject: [PATCH] added versioning --- README.md | 160 +++++++++++++++++++++++++++++++++++------------ src/main.c | 165 +++++++++++++++++++++++++++++++++++++++++++++++-- web/app.js | 69 ++++++++++++++++++--- web/index.html | 36 +++++++++++ web/style.css | 35 +++++++++++ 5 files changed, 413 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 8d47ac8..f4815ca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bootloader Update Manager -Веб-приложение на C для обновления ядра, device-tree и U-Boot environment на Orange Pi Zero 2W. +Веб-приложение на C для обновления ядра, device-tree и U-Boot environment на Orange Pi Zero 2W (Allwinner H618). ## Возможности @@ -10,30 +10,46 @@ - **U-Boot Environment**: Загрузка `uboot.env` - **U-Boot Binary**: Загрузка `u-boot-sunxi-with-spl.bin` с записью в: - `/media/u-boot-sunxi-with-spl.bin` (файл) - - `/dev/mtd0` (SPI NOR flash) + - `/dev/mtd0` (SPI NOR flash, с предварительным стиранием) - `/dev/mmcblk1` со смещением 8KB (SD карта, аналог `dd seek=8`) - **Редактор extlinux.conf**: Просмотр и редактирование конфигурации загрузчика - **Индикатор прогресса**: Отображение прогресса загрузки в реальном времени - **Авто-монтирование**: Автоматически монтирует `/dev/mmcblk1p1` в `/media` -- **Поддержка больших файлов**: Загрузка файлов до 200 MB (до 1 MB для U-Boot Binary) -- **Перезагрузка системы**: Кнопка безопасной перезагрузки с корректным отмонтированием -- **Мониторинг температуры**: Отображение температуры CPU с цветовой индикацией +- **Поддержка больших файлов**: + - До 200 MB для обычных файлов + - До 1 MB для U-Boot Binary (безопасный лимит) +- **Перезагрузка системы**: Кнопка безопасной перезагрузки: + - `sync()` - сброс буферов ФС + - `umount /media` - отмонтирование + - `shutdown -r now` - мягкая перезагрузка через finit +- **Мониторинг температуры**: Отображение температуры CPU с цветовой индикацией: + - < 50°C: 🟢 Зелёный (холодно) + - < 70°C: 🔵 Синий (норма) + - < 80°C: 🟠 Оранжевый (тепло) + - ≥ 80°C: 🔴 Красный (горячо) +- **Отслеживание версий файлов**: + - Автоматическое сохранение даты/времени загрузки + - Файлы `.version` для каждого загруженного файла + - Timestamp передаётся от браузера (не зависит от времени на плате) + - Человекочитаемый формат: `ГГГГ-ММ-ДД ЧЧ:ММ:СС` + - Отображение последней даты модификации в веб-интерфейсе ## Структура проекта ``` . -├── main.c # Основной сервер (C + mongoose + cJSON) -├── index.html # HTML веб-интерфейса -├── style.css # Стили веб-интерфейса -├── app.js # Frontend JavaScript -├── Makefile # Конфигурация сборки -├── README.md # Документация -├── mongoose.h # Mongoose web server (v7.19) -├── mongoose.c # Mongoose web server (v7.19) -├── cJSON.h # JSON parser (v1.7.18) -├── cJSON.c # JSON parser (v1.7.18) -└── mongoose_config.h # Конфигурация mongoose +├── src/ +│ ├── main.c # Основной сервер (C + mongoose + cJSON) +│ └── lib/ +│ ├── mongoose.c/h # Mongoose web server (v7.19) +│ └── cJSON.c/h # JSON parser (v1.7.18) +├── web/ +│ ├── index.html # HTML веб-интерфейса +│ ├── style.css # Стили веб-интерфейса +│ └── app.js # Frontend JavaScript +├── mongoose_config.h # Конфигурация mongoose +├── Makefile # Конфигурация сборки +└── README.md # Документация ``` ## Требования @@ -62,53 +78,72 @@ sudo ./up ### System Status - Статус монтирования `/dev/mmcblk1p1` → `/media` -- Информация о устройстве +- Температура CPU (обновление каждые 5 секунд) + +### File Versions +Отображение даты последней загрузки/изменения для файлов: +- Kernel (Image.gz) +- Device-Tree (DTB) +- U-Boot Env +- U-Boot Binary +- Extlinux (обновляется при сохранении через редактор) + +**Формат даты**: `2026-03-14 12:34:56` + +**Примечание**: Время берётся из браузера клиента, поэтому корректно даже если на плате не настроено системное время. ### File Upload -Три вкладки для загрузки: +Четыре вкладки для загрузки: - **Kernel** → `/media/Image.gz` - **Device-Tree** → `/media/sun50i-h618-orangepi-zero2w.dtb` - **U-Boot Environment** → `/media/uboot.env` +- **U-Boot Binary (SPL)** → `/media/u-boot-sunxi-with-spl.bin` + `/dev/mtd0` + `/dev/mmcblk1` (+8KB) + +При загрузке браузер автоматически отправляет текущий timestamp, который сохраняется в `.version` файл. ### extlinux.conf Editor - Загрузка текущего `extlinux.conf` - Редактирование в текстовом поле -- Сохранение изменений +- Сохранение изменений (с записью версии) +- Очистка редактора ### System Control - Кнопка **Reboot System** с подтверждением -- Безопасная перезагрузка: - - `sync()` - сброс буферов ФС - - `umount /media` - отмонтирование - - `shutdown -r now` - мягкая перезагрузка через finit +- Безопасная перезагрузка через `shutdown -r now` ## API Endpoints | Endpoint | Method | Описание | |----------|--------|----------| -| `/api/upload` | POST | Загрузка файлов (kernel, dtb, uboot) | -| `/api/extlinux` | GET | Чтение extlinux.conf | -| `/api/extlinux` | POST | Запись extlinux.conf | +| `/api/upload?timestamp=...` | POST | Загрузка файлов (kernel, dtb, uboot, uboot_bin) | +| `/api/extlinux?timestamp=...` | GET | Чтение extlinux.conf | +| `/api/extlinux?timestamp=...` | POST | Запись extlinux.conf (с версией) | | `/api/progress` | GET | Статус загрузки | | `/api/mount` | GET | Статус монтирования | | `/api/temperature` | GET | Температура CPU | +| `/api/filedate?file=...` | GET | Дата версии файла | | `/api/reboot` | POST | Перезагрузка системы | ## Параметры загрузки -Для `/api/upload` (multipart/form-data): -- `type`: Тип файла (`kernel`, `dtb`, `uboot`) +Для `/api/upload`: +- `type`: Тип файла (`kernel`, `dtb`, `uboot`, `uboot_bin`) - `file`: Загружаемый файл +- `timestamp`: ISO 8601 timestamp (отправляется браузером автоматически) + +Для `/api/extlinux` (POST): +- `content`: Содержимое файла extlinux.conf +- `timestamp`: ISO 8601 timestamp (отправляется браузером автоматически) ## Расположение файлов -| Тип | Файл назначения | -|-----|-----------------| -| Ядро | `/media/Image.gz` | -| Device-Tree | `/media/sun50i-h618-orangepi-zero2w.dtb` | -| U-Boot Env | `/media/uboot.env` | -| U-Boot Binary | `/media/u-boot-sunxi-with-spl.bin` + `/dev/mtd0` + `/dev/mmcblk1` (+8KB offset) | -| Extlinux | `/media/extlinux/extlinux.conf` | +| Тип | Файл назначения | Версия | +|-----|-----------------|--------| +| Ядро | `/media/Image.gz` | `/media/Image.gz.version` | +| Device-Tree | `/media/sun50i-h618-orangepi-zero2w.dtb` | `/media/sun50i-h618-orangepi-zero2w.dtb.version` | +| U-Boot Env | `/media/uboot.env` | `/media/uboot.env.version` | +| U-Boot Binary | `/media/u-boot-sunxi-with-spl.bin` + `/dev/mtd0` + `/dev/mmcblk1` (+8KB) | `/media/u-boot-sunxi-with-spl.bin.version` | +| Extlinux | `/media/extlinux/extlinux.conf` | `/media/extlinux.conf.version` | ## Конфигурация @@ -119,21 +154,66 @@ sudo ./up ``` ### Ограничения -- Максимальный размер файла: **200 MB** +- Максимальный размер файла: **200 MB** (обычные файлы) +- Максимальный размер U-Boot: **1 MB** (безопасно для boot area) - Порт сервера: **43243** +## Детали реализации + +### U-Boot Binary загрузка +1. Сохранение файла в `/media/u-boot-sunxi-with-spl.bin` +2. Получение информации о SPI NOR через `ioctl(MEMGETINFO)` +3. Стирание SPI NOR через `ioctl(MEMERASE)` +4. Запись в `/dev/mtd0` (SPI NOR flash) +5. Запись в `/dev/mmcblk1` со смещением 8KB (сектор 16) +6. `sync()` для гарантии записи + +**Прогресс бар**: +- 0-30%: Запись файла в `/media` +- 30-65%: Запись в SPI NOR +- 65-100%: Запись в SD карту + +### Temperature monitoring +- Чтение из `/sys/class/thermal/thermal_zone*` (0-3) +- Приоритет: зоны с типом `cpu`, `soc` +- Резервный источник: `/sys/class/hwmon/hwmon*/temp1_input` (0-3) +- Приоритет: сенсоры с именем `cpu`, `sunxi`, `acpitz` +- Обновление каждые 5 секунд + +### File version tracking +1. Браузер создаёт ISO 8601 timestamp: `2026-03-14T12:34:56.789Z` +2. Timestamp кодируется для URL: `2026-03-14T12%3A34%3A56.789Z` +3. Сервер декодирует `%3A` → `:` +4. Конвертирует в человекочитаемый формат: `2026-03-14 12:34:56` +5. Сохраняет в `.version` +6. Frontend опрашивает `/api/filedate` каждые 10 секунд + +**Важно**: Время берётся из браузера клиента, поэтому: +- ✅ Корректное время даже если на плате не настроено +- ✅ Часовой пояс клиента +- ✅ Синхронизация через NTP браузера + +### Extlinux.conf editor +- Сохранение через POST с timestamp от браузера +- Автоматическое обновление списка версий после сохранения +- Версия записывается в `/media/extlinux.conf.version` + ## Безопасность ⚠️ **Предупреждение**: -- Приложение требует права **root** для монтирования и перезагрузки +- Приложение требует права **root** для: + - Монтирования устройств (`mount`, `umount`) + - Записи в `/dev/mtd0` (SPI NOR flash) + - Записи в `/dev/mmcblk1` (SD карта) + - Перезагрузки системы (`shutdown`) - Используйте с осторожностью в production окружении -- Рекомендуется ограничить доступ к веб-интерфейсу +- Рекомендуется ограничить доступ к веб-интерфейсу (firewall, auth) ## Зависимости Все зависимости встроены в проект: -- `mongoose.c/h` - веб-сервер (из репозитория Cesanta) -- `cJSON.c/h` - JSON парсер (из репозитория DaveGamble) +- `mongoose.c/h` - веб-сервер (из репозитория Cesanta, v7.19) +- `cJSON.c/h` - JSON парсер (из репозитория DaveGamble, v1.7.18) ## Лицензия diff --git a/src/main.c b/src/main.c index 08d31cf..b0846c2 100644 --- a/src/main.c +++ b/src/main.c @@ -344,6 +344,25 @@ static void handle_extlinux_write(struct mg_connection *c, struct mg_http_messag return; } + // Get timestamp from query string (sent by browser) + char timestamp[64] = {0}; + if (hm->query.len > 0) { + char query[512] = {0}; + memcpy(query, hm->query.buf, hm->query.len < sizeof(query) ? hm->query.len : sizeof(query)-1); + struct mg_str ts_name = mg_str("timestamp"); + struct mg_str ts_value = mg_http_var(mg_str(query), ts_name); + if (ts_value.len > 0) { + mg_str_cpy(ts_value, timestamp, sizeof(timestamp)); + // URL-decode timestamp (replace %3A with :) + for (size_t i = 0; i < strlen(timestamp) - 2; i++) { + if (timestamp[i] == '%' && timestamp[i+1] == '3' && timestamp[i+2] == 'A') { + timestamp[i] = ':'; + memmove(×tamp[i+1], ×tamp[i+3], strlen(×tamp[i+3]) + 1); + } + } + } + } + if (mount_device() < 0) { cJSON_Delete(root); mg_http_reply(c, 500, "", "{\"error\":\"Failed to mount device\"}\n"); @@ -362,6 +381,31 @@ static void handle_extlinux_write(struct mg_connection *c, struct mg_http_messag fprintf(f, "%s", content->valuestring); fclose(f); + + // Write version file with timestamp from browser + if (strlen(timestamp) > 0) { + char version_path[512]; + snprintf(version_path, sizeof(version_path), "%s/%s.version", MOUNT_POINT, "extlinux.conf"); + FILE *vf = fopen(version_path, "w"); + if (vf) { + // Convert ISO 8601 to human-readable format + // Input: 2026-03-14T12:34:56.789Z + // Output: 2026-03-14 12:34:56 + char readable_date[32] = {0}; + if (strlen(timestamp) >= 19) { + strncpy(readable_date, timestamp, 10); // YYYY-MM-DD + readable_date[10] = ' '; + strncpy(readable_date + 11, timestamp + 11, 8); // HH:MM:SS + readable_date[19] = '\0'; + } else { + strncpy(readable_date, timestamp, sizeof(readable_date) - 1); + } + fprintf(vf, "%s\n", readable_date); + fclose(vf); + printf("Written extlinux.conf version: %s\n", readable_date); + } + } + sync(); mg_http_reply(c, 200, "Content-Type: application/json\r\n", @@ -431,10 +475,10 @@ static void handle_temperature(struct mg_connection *c) { char type[32] = {0}; if (fscanf(type_f, "%31s", type) == 1) { // Prefer CPU or soc zones - int is_cpu = (strstr(type, "cpu") != NULL || + int is_cpu = (strstr(type, "cpu") != NULL || strstr(type, "soc") != NULL || strstr(type, "CPU") != NULL); - + int temp_raw; if (fscanf(f, "%d", &temp_raw) == 1) { temperature = temp_raw / 1000.0f; @@ -467,11 +511,11 @@ static void handle_temperature(struct mg_connection *c) { char name[32] = {0}; if (fscanf(name_f, "%31s", name) == 1) { // Prefer CPU or sunxi sensors - int is_cpu = (strstr(name, "cpu") != NULL || + int is_cpu = (strstr(name, "cpu") != NULL || strstr(name, "sunxi") != NULL || strstr(name, "CPU") != NULL || strstr(name, "acpitz") != NULL); - + int temp_raw; if (fscanf(f, "%d", &temp_raw) == 1) { temperature = temp_raw / 1000.0f; @@ -499,7 +543,7 @@ static void handle_temperature(struct mg_connection *c) { "/sys/class/hwmon/hwmon3/temp1_input", NULL }; - + for (int i = 0; fallback_paths[i] != NULL && !success; i++) { FILE *f = fopen(fallback_paths[i], "r"); if (f) { @@ -524,6 +568,72 @@ static void handle_temperature(struct mg_connection *c) { cJSON_Delete(root); } +// Handle file date read +static void handle_filedate(struct mg_connection *c, struct mg_http_message *hm) { + // Get filename from query string + char filename[128] = {0}; + char query[256] = {0}; + + if (hm->query.len > 0 && hm->query.len < sizeof(query)) { + memcpy(query, hm->query.buf, hm->query.len); + query[hm->query.len] = '\0'; + + // Parse query string: file=Image.gz + struct mg_str name = mg_str("file"); + struct mg_str value = mg_http_var(mg_str(query), name); + if (value.len > 0 && value.len < sizeof(filename)) { + mg_str_cpy(value, filename, sizeof(filename)); + } + } + + if (strlen(filename) == 0) { + mg_http_reply(c, 400, "", "{\"error\":\"Missing file parameter\"}\n"); + return; + } + + // Build version file path + char version_path[512]; + snprintf(version_path, sizeof(version_path), "%s/%s.version", MOUNT_POINT, filename); + + // Try to read version file + FILE *f = fopen(version_path, "r"); + if (!f) { + // No version file, return empty date + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "status", "success"); + cJSON_AddStringToObject(root, "date", ""); + cJSON_AddStringToObject(root, "filename", filename); + + char *json = cJSON_PrintUnformatted(root); + mg_http_reply(c, 200, "Content-Type: application/json\r\n", "%s\n", json); + free(json); + cJSON_Delete(root); + return; + } + + char date_str[64] = {0}; + if (fgets(date_str, sizeof(date_str), f) == NULL) { + date_str[0] = '\0'; + } + fclose(f); + + // Remove newline + size_t len = strlen(date_str); + if (len > 0 && date_str[len-1] == '\n') { + date_str[len-1] = '\0'; + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "status", "success"); + cJSON_AddStringToObject(root, "date", date_str); + cJSON_AddStringToObject(root, "filename", filename); + + char *json = cJSON_PrintUnformatted(root); + mg_http_reply(c, 200, "Content-Type: application/json\r\n", "%s\n", json); + free(json); + cJSON_Delete(root); +} + // Handle system reboot static void handle_reboot(struct mg_connection *c) { cJSON *root = cJSON_CreateObject(); @@ -619,6 +729,25 @@ static void fn(struct mg_connection *c, int ev, void *ev_data) { g_upload.progress = 0; snprintf(g_upload.status, sizeof(g_upload.status), "Uploading..."); + // Get timestamp from query string (sent by browser) + char timestamp[64] = {0}; + if (hm->query.len > 0) { + char query[512] = {0}; + memcpy(query, hm->query.buf, hm->query.len < sizeof(query) ? hm->query.len : sizeof(query)-1); + struct mg_str ts_name = mg_str("timestamp"); + struct mg_str ts_value = mg_http_var(mg_str(query), ts_name); + if (ts_value.len > 0) { + mg_str_cpy(ts_value, timestamp, sizeof(timestamp)); + // URL-decode timestamp (replace %3A with :) + for (size_t i = 0; i < strlen(timestamp) - 2; i++) { + if (timestamp[i] == '%' && timestamp[i+1] == '3' && timestamp[i+2] == 'A') { + timestamp[i] = ':'; + memmove(×tamp[i+1], ×tamp[i+3], strlen(×tamp[i+3]) + 1); + } + } + } + } + // Special handling for U-Boot binary if (strcmp(type, "uboot_bin") == 0) { if (write_uboot_to_devices(file_data.buf, file_data.len, filepath) < 0) { @@ -653,6 +782,30 @@ static void fn(struct mg_connection *c, int ev, void *ev_data) { g_upload.progress = 100; } + // Write version file with timestamp + if (strlen(timestamp) > 0) { + char version_path[512]; + snprintf(version_path, sizeof(version_path), "%s/%s.version", MOUNT_POINT, filename); + FILE *vf = fopen(version_path, "w"); + if (vf) { + // Convert ISO 8601 to human-readable format + // Input: 2026-03-14T12:34:56.789Z + // Output: 2026-03-14 12:34:56 + char readable_date[32] = {0}; + if (strlen(timestamp) >= 19) { + strncpy(readable_date, timestamp, 10); // YYYY-MM-DD + readable_date[10] = ' '; + strncpy(readable_date + 11, timestamp + 11, 8); // HH:MM:SS + readable_date[19] = '\0'; + } else { + strncpy(readable_date, timestamp, sizeof(readable_date) - 1); + } + fprintf(vf, "%s\n", readable_date); + fclose(vf); + printf("Written version file: %s (%s)\n", version_path, readable_date); + } + } + printf("Upload complete: %s\n", filepath); g_upload.received = file_data.len; @@ -683,6 +836,8 @@ static void fn(struct mg_connection *c, int ev, void *ev_data) { handle_mount_status(c); } else if (mg_match_uri(hm->uri, "/api/temperature")) { handle_temperature(c); + } else if (mg_match_uri(hm->uri, "/api/filedate")) { + handle_filedate(c, hm); } else if (mg_match_uri(hm->uri, "/api/reboot")) { handle_reboot(c); } else if (mg_match_uri(hm->uri, "/")) { diff --git a/web/app.js b/web/app.js index 195f098..adda793 100644 --- a/web/app.js +++ b/web/app.js @@ -23,8 +23,10 @@ document.addEventListener("DOMContentLoaded", function () { // Initialize checkMountStatus(); checkTemperature(); + checkFileDates(); setInterval(checkMountStatus, 10000); // Check every 10 seconds setInterval(checkTemperature, 5000); // Check temperature every 5 seconds + setInterval(checkFileDates, 10000); // Check file dates every 10 seconds // Tab switching tabBtns.forEach((btn) => { @@ -114,6 +116,9 @@ document.addEventListener("DOMContentLoaded", function () { progressBytes.textContent = ""; hideResult(); + // Get current timestamp + const timestamp = new Date().toISOString(); + try { const xhr = new XMLHttpRequest(); @@ -135,6 +140,8 @@ document.addEventListener("DOMContentLoaded", function () { `Successfully uploaded ${response.filename} (${formatBytes(response.size)})`, "success", ); + // Refresh file dates after upload + checkFileDates(); } else { let errorMsg = "Upload failed"; try { @@ -151,7 +158,10 @@ document.addEventListener("DOMContentLoaded", function () { showResult("Network error", "error"); }); - xhr.open("POST", "/api/upload"); + xhr.open( + "POST", + `/api/upload?timestamp=${encodeURIComponent(timestamp)}`, + ); xhr.send(formData); } catch (error) { progressStatus.textContent = "Failed"; @@ -227,19 +237,27 @@ document.addEventListener("DOMContentLoaded", function () { showEditorStatus("Saving...", ""); + // Get current timestamp from browser + const timestamp = new Date().toISOString(); + try { - const response = await fetch("/api/extlinux", { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetch( + `/api/extlinux?timestamp=${encodeURIComponent(timestamp)}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ content: content }), }, - body: JSON.stringify({ content: content }), - }); + ); const data = await response.json(); if (data.status === "success") { showEditorStatus("Successfully saved extlinux.conf", "success"); + // Refresh file dates after save + checkFileDates(); } else { showEditorStatus(data.error || "Failed to save", "error"); } @@ -349,4 +367,41 @@ document.addEventListener("DOMContentLoaded", function () { if (temp < 80) return "#ffa500"; // Orange - warm return "#ff4757"; // Red - hot } + + // File dates functions + const fileDates = { + "Image.gz": document.getElementById("date-Image.gz"), + "sun50i-h618-orangepi-zero2w.dtb": document.getElementById( + "date-sun50i-h618-orangepi-zero2w.dtb", + ), + "uboot.env": document.getElementById("date-uboot.env"), + "u-boot-sunxi-with-spl.bin": document.getElementById( + "date-u-boot-sunxi-with-spl.bin", + ), + "extlinux.conf": document.getElementById("date-extlinux.conf"), + }; + + async function checkFileDates() { + for (const [filename, element] of Object.entries(fileDates)) { + if (!element) continue; + + try { + const response = await fetch( + `/api/filedate?file=${encodeURIComponent(filename)}`, + ); + const data = await response.json(); + + if (data.status === "success" && data.date) { + element.textContent = data.date; + element.style.color = "#00ff88"; + } else { + element.textContent = "Not available"; + element.style.color = "#888"; + } + } catch (error) { + element.textContent = "Error"; + element.style.color = "#ff4757"; + } + } + } }); diff --git a/web/index.html b/web/index.html index 623e9a3..83d8ea7 100644 --- a/web/index.html +++ b/web/index.html @@ -44,6 +44,42 @@ +
+

File Versions

+
+
+ Kernel (Image.gz): + -- +
+
+ Device-Tree (DTB): + -- +
+
+ U-Boot Env: + -- +
+
+ U-Boot Binary: + -- +
+
+ Extlinux: + -- +
+
+
+

File Upload

diff --git a/web/style.css b/web/style.css index eecd3e2..fbe8f27 100644 --- a/web/style.css +++ b/web/style.css @@ -113,6 +113,41 @@ h2 { font-size: 0.9rem; } +/* File Versions Section */ +.files-section { + border: 1px solid rgba(0, 217, 255, 0.3); +} + +.files-section h2 { + color: #00d9ff; +} + +.files-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; +} + +.file-item { + background: rgba(0, 0, 0, 0.2); + padding: 12px 15px; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.file-label { + color: #888; + font-size: 0.85rem; +} + +.file-date { + color: #00ff88; + font-weight: 600; + font-size: 0.95rem; +} + /* Upload Section */ .upload-tabs { display: flex;