added versioning
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-Make (push) Successful in 6s
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-CMake (push) Successful in 11s
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-Meson (push) Successful in 9s

This commit is contained in:
wt
2026-03-14 07:15:04 +07:00
parent 844f839c51
commit 9ce171911d
5 changed files with 413 additions and 52 deletions
+120 -40
View File
@@ -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. Сохраняет в `<filename>.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)
## Лицензия
+160 -5
View File
@@ -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(&timestamp[i+1], &timestamp[i+3], strlen(&timestamp[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(&timestamp[i+1], &timestamp[i+3], strlen(&timestamp[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, "/")) {
+62 -7
View File
@@ -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";
}
}
}
});
+36
View File
@@ -44,6 +44,42 @@
</div>
</section>
<section class="files-section">
<h2>File Versions</h2>
<div class="files-grid">
<div class="file-item">
<span class="file-label">Kernel (Image.gz):</span>
<span class="file-date" id="date-Image.gz">--</span>
</div>
<div class="file-item">
<span class="file-label">Device-Tree (DTB):</span>
<span
class="file-date"
id="date-sun50i-h618-orangepi-zero2w.dtb"
>--</span
>
</div>
<div class="file-item">
<span class="file-label">U-Boot Env:</span>
<span class="file-date" id="date-uboot.env">--</span>
</div>
<div class="file-item">
<span class="file-label">U-Boot Binary:</span>
<span
class="file-date"
id="date-u-boot-sunxi-with-spl.bin"
>--</span
>
</div>
<div class="file-item">
<span class="file-label">Extlinux:</span>
<span class="file-date" id="date-extlinux.conf"
>--</span
>
</div>
</div>
</section>
<section class="upload-section">
<h2>File Upload</h2>
<div class="upload-tabs">
+35
View File
@@ -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;