diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml
new file mode 100644
index 0000000..13ab091
--- /dev/null
+++ b/.github/workflows/code-style.yml
@@ -0,0 +1,80 @@
+name: Code Style Check
+
+on:
+ pull_request:
+ branches:
+ - develop
+ - main
+ push:
+ branches:
+ - develop
+ - main
+
+jobs:
+ backend-style:
+ name: Backend Ruff
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install backend dev dependencies
+ working-directory: backend
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements-dev.txt
+
+ - name: Run Ruff lint
+ working-directory: backend
+ run: python -m ruff check .
+
+ - name: Run Ruff format check
+ working-directory: backend
+ run: python -m ruff format --check .
+
+ frontend-style:
+ name: Frontend Prettier
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: "npm"
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install frontend dependencies
+ working-directory: frontend
+ run: npm ci
+
+ - name: Run Prettier check
+ working-directory: frontend
+ run: npm run format:check
+
+ embedded-style:
+ name: Embedded clang-format
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install clang-format
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y clang-format
+
+ - name: Run clang-format check
+ run: |
+ git ls-files 'embedded/ssccam/**/*.c' 'embedded/ssccam/**/*.h' \
+ | xargs clang-format --dry-run -Werror
diff --git a/.gitignore b/.gitignore
index 07c32e8..4c264db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ uploaded_images/
.devcontainer/
.clangd
Kconfig.projbuild
+node_modules/
# ========================================================================
# Python
diff --git a/backend/main.py b/backend/main.py
index 97c9f86..65266b5 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,18 +1,19 @@
-from fastapi import FastAPI, File, UploadFile
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse
-from fastapi.staticfiles import StaticFiles
import os
+import time
+from contextlib import asynccontextmanager
from datetime import datetime
+from decimal import Decimal
+from typing import Annotated
+
import cv2
import numpy as np
-from ultralytics import YOLO
-import time
-from decimal import Decimal
-import psycopg
from dotenv import load_dotenv
-from contextlib import asynccontextmanager
+from fastapi import FastAPI, File, UploadFile
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
from psycopg_pool import ConnectionPool
+from ultralytics import YOLO
load_dotenv()
@@ -20,13 +21,14 @@
db_pool = None
+
@asynccontextmanager
-async def lifespan(app:FastAPI):
+async def lifespan(app: FastAPI):
global db_pool
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL is not set")
-
+
db_pool = ConnectionPool(
conninfo=DATABASE_URL,
min_size=1,
@@ -39,6 +41,7 @@ async def lifespan(app:FastAPI):
finally:
db_pool.close()
+
app = FastAPI(title="SSCCounter API", version="2.1", lifespan=lifespan)
app.mount("/data", StaticFiles(directory="../frontend/data"), name="data")
@@ -48,7 +51,8 @@ async def lifespan(app:FastAPI):
# ---------------------------------------------
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"], # 모든 도메인 허용 (개발 단계에서는 편리하지만, 배포 시에는 보안을 위해 특정 도메인만 허용하는 것이 좋습니다)
+ # 개발 단계에서는 모든 도메인을 허용합니다.
+ allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -59,17 +63,23 @@ async def lifespan(app:FastAPI):
model = YOLO("yolov8n.pt")
print("YOLO model loaded successfully.")
+
def infer_people_count(frame):
- results = model.predict(source=frame, conf=0.3, classes=[0], imgsz=416, verbose=False)
+ results = model.predict(
+ source=frame, conf=0.3, classes=[0], imgsz=416, verbose=False
+ )
return (results[0].boxes.cls == 0).sum().item()
+
# ---------------------------------------------
+
def get_db_connection():
if db_pool is None:
raise RuntimeError("Database connection pool is not initialized")
return db_pool.connection()
+
def to_float(value):
if value is None:
return 0.0
@@ -77,51 +87,66 @@ def to_float(value):
return float(value)
return value
+
def get_current_status_from_db():
with get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
- SELECT current_people_count, updated_at, today_max_people_count, today_avg_people_count
+ SELECT
+ current_people_count,
+ updated_at,
+ today_max_people_count,
+ today_avg_people_count
FROM dashboard.current_status
WHERE id = 1
"""
)
row = cur.fetchone()
-
+
if row is None:
return {
"count": 0,
"updated_at": None,
"today_max_count": 0,
- "today_avg_count": 0.0
+ "today_avg_count": 0.0,
}
-
+
return {
"count": row[0],
"updated_at": row[1].isoformat() if row[1] else None,
"today_max_count": row[2],
- "today_avg_count": to_float(row[3])
+ "today_avg_count": to_float(row[3]),
}
-def save_failed_inference(error_message: str, trigger_type: str = "scheduled", inference_time_ms: int | None = None):
+
+def save_failed_inference(
+ error_message: str,
+ trigger_type: str = "scheduled",
+ inference_time_ms: int | None = None,
+):
with get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
- INSERT INTO admin.capture_logs (people_count, status, trigger_type, inference_time_ms, error_message)
- VALUES (%s, %s, %s, %s, %s)
- """,
- (
- None,
- "failed",
+ INSERT INTO admin.capture_logs (
+ people_count,
+ status,
trigger_type,
inference_time_ms,
error_message
)
+ VALUES (%s, %s, %s, %s, %s)
+ """,
+ (None, "failed", trigger_type, inference_time_ms, error_message),
)
-def save_successful_inference(people_count: int, trigger_type: str = "scheduled", inference_time_ms: int | None = None):
+
+def save_successful_inference(
+ people_count: int,
+ trigger_type: str = "scheduled",
+ inference_time_ms: int | None = None,
+):
"""
YOLO 추론 성공 결과를 admin raw log와 dashboard summary tables에 반영합니다.
@@ -137,16 +162,16 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled"
# 1. 관리자용 raw log 저장
cur.execute(
"""
- INSERT INTO admin.capture_logs (people_count, status, trigger_type, inference_time_ms)
- VALUES (%s, %s, %s, %s)
- RETURNING measured_at
- """,
- (
+ INSERT INTO admin.capture_logs (
people_count,
- "success",
+ status,
trigger_type,
inference_time_ms
)
+ VALUES (%s, %s, %s, %s)
+ RETURNING measured_at
+ """,
+ (people_count, "success", trigger_type, inference_time_ms),
)
measured_at = cur.fetchone()[0]
@@ -156,7 +181,10 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled"
WITH today_summary AS (
SELECT
COALESCE(MAX(people_count), 0) AS today_max_people_count,
- COALESCE(ROUND(AVG(people_count)::numeric, 2), 0) AS today_avg_people_count
+ COALESCE(
+ ROUND(AVG(people_count)::numeric, 2),
+ 0
+ ) AS today_avg_people_count
FROM admin.capture_logs
WHERE status = 'success'
AND measured_at >= date_trunc('day', now())
@@ -183,10 +211,7 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled"
today_max_people_count = EXCLUDED.today_max_people_count,
today_avg_people_count = EXCLUDED.today_avg_people_count
""",
- (
- people_count,
- measured_at
- )
+ (people_count, measured_at),
)
# 3. dashboard.today_hourly_stats 갱신
@@ -254,7 +279,10 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled"
summary AS (
SELECT
current_date AS stat_date,
- COALESCE(ROUND(AVG(people_count)::numeric, 2), 0) AS avg_people_count,
+ COALESCE(
+ ROUND(AVG(people_count)::numeric, 2),
+ 0
+ ) AS avg_people_count,
COALESCE(MAX(people_count), 0) AS max_people_count,
COALESCE(MIN(people_count), 0) AS min_people_count,
COUNT(*) AS sample_count
@@ -348,6 +376,7 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled"
"""
)
+
def get_today_stats_from_db():
with get_db_connection() as conn:
with conn.cursor() as cur:
@@ -363,29 +392,18 @@ def get_today_stats_from_db():
)
rows = cur.fetchall()
- return [
- {
- "hour": row[0],
- "count": row[1]
- }
- for row in rows
- ]
+ return [{"hour": row[0], "count": row[1]} for row in rows]
+
def get_weekly_stats_from_db():
- day_key_map = {
- 1: "mon",
- 2: "tue",
- 3: "wed",
- 4: "thu",
- 5: "fri"
- }
+ day_key_map = {1: "mon", 2: "tue", 3: "wed", 4: "thu", 5: "fri"}
weekly_stats = {
"mon": [0] * 14,
"tue": [0] * 14,
"wed": [0] * 14,
"thu": [0] * 14,
- "fri": [0] * 14
+ "fri": [0] * 14,
}
with get_db_connection() as conn:
@@ -407,17 +425,19 @@ def get_weekly_stats_from_db():
day_key = day_key_map.get(weekday)
if not day_key:
continue
-
+
index = hour - 9
if 0 <= index < 14:
weekly_stats[day_key][index] = to_float(avg_people_count)
return weekly_stats
+
# ---------------------------------------------
SHOULD_CAPTURE = False
+
@app.post("/api/v2/test/trigger")
async def trigger_capture():
"""
@@ -427,6 +447,7 @@ async def trigger_capture():
SHOULD_CAPTURE = True
return {"status": "success", "message": "Capture command issued to ESP32."}
+
@app.get("/api/v2/device/command")
async def get_device_command():
"""
@@ -434,12 +455,14 @@ async def get_device_command():
"""
global SHOULD_CAPTURE
if SHOULD_CAPTURE:
- SHOULD_CAPTURE = False # 명령을 전달했으므로 다시 대기 상태로 변경
+ SHOULD_CAPTURE = False # 명령을 전달했으므로 다시 대기 상태로 변경
return {"command": "capture"}
return {"command": "idle"}
+
# --------------------------------------------
+
@app.get("/")
async def root():
"""
@@ -447,6 +470,7 @@ async def root():
"""
return FileResponse("../frontend/index.html")
+
@app.get("/api/v2/count/current")
async def get_current_count():
"""
@@ -454,6 +478,7 @@ async def get_current_count():
"""
return get_current_status_from_db()
+
@app.get("/api/v2/stats/today")
async def get_today_stats():
"""
@@ -461,6 +486,7 @@ async def get_today_stats():
"""
return get_today_stats_from_db()
+
@app.get("/api/v2/stats/weekly")
async def get_weekly_stats():
"""
@@ -468,10 +494,12 @@ async def get_weekly_stats():
"""
return get_weekly_stats_from_db()
+
# --------------------------------------------
+
@app.post("/api/v2/device/upload")
-async def upload_image(file: UploadFile = File(...)):
+async def upload_image(file: Annotated[UploadFile, File(...)]):
"""
[ESP32용] ESP32-CAM이 업로드한 이미지를 서버 메모리에서만 처리합니다.
@@ -498,31 +526,36 @@ async def upload_image(file: UploadFile = File(...)):
save_failed_inference(error_message=error_message, trigger_type="scheduled")
- return {
- "status": "error",
- "message": error_message
- }
+ return {"status": "error", "message": error_message}
inference_start = time.perf_counter()
people_count = infer_people_count(frame)
inference_time_ms = int((time.perf_counter() - inference_start) * 1000)
- save_successful_inference(people_count=people_count, trigger_type="scheduled", inference_time_ms=inference_time_ms)
+ save_successful_inference(
+ people_count=people_count,
+ trigger_type="scheduled",
+ inference_time_ms=inference_time_ms,
+ )
- print(f"[{measured_at}] Inference Result: {people_count} people detected. Inference Time: {inference_time_ms} ms")
+ print(
+ f"[{measured_at}] Inference Result: "
+ f"{people_count} people detected. "
+ f"Inference Time: {inference_time_ms} ms"
+ )
return {
"status": "success",
"message": "Image processed successfully",
- "detected_count": people_count
+ "detected_count": people_count,
}
except Exception as e:
error_message = str(e)
print(f"Error during upload/inference: {error_message}")
- try:
+ try:
save_failed_inference(error_message=error_message, trigger_type="scheduled")
except Exception as db_error:
print(f"Failed to save error log to DB: {str(db_error)}")
- return {"status": "error", "message": error_message}
\ No newline at end of file
+ return {"status": "error", "message": error_message}
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..804a5a1
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,18 @@
+[tool.ruff]
+line-length = 88
+target-version = "py312"
+
+[tool.ruff.lint]
+select = [
+ "E",
+ "F",
+ "I",
+ "B",
+ "UP",
+]
+ignore = []
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+line-ending = "auto"
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..af3ee57
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1 @@
+ruff
diff --git a/docs/code-style.md b/docs/code-style.md
new file mode 100644
index 0000000..dbe730a
--- /dev/null
+++ b/docs/code-style.md
@@ -0,0 +1,64 @@
+## Code Style Check
+
+SSCCounter는 monorepo 구조로 FE, BE, Embedded 코드를 함께 관리합니다.
+PR 생성 시 GitHub Actions에서 각 영역의 코드 스타일 검사를 자동으로 수행합니다.
+
+### Backend
+
+Backend는 Ruff를 사용합니다.
+
+```bash
+cd backend
+python -m pip install -r requirements-dev.txt
+python -m ruff check .
+python -m ruff format --check .
+```
+
+자동 수정:
+```bash
+python -m ruff check. --fix
+python -m ruff format .
+```
+
+### Frontend
+
+Frontend는 Prettier를 사용합니다.
+```bash
+cd frontend
+npm install
+npm run format:check
+```
+
+자동 수정:
+```
+npm run format
+```
+
+### Embedded
+
+Embedded는 clang-format을 사용합니다.
+```bash
+git ls-files 'embedded/ssccam/**/*.c' 'embedded/ssccam/**/*.h' \
+ | xargs clang-format --dry-run -Werror
+```
+
+자동 수정:
+```bash
+git ls-files 'embedded/ssccam/**/*.c' 'embedded/ssccam/**/*.h' \
+ | xargs clang-format -i
+```
+
+### PR 전 확인
+
+```bash
+cd backend
+python -m ruff check .
+python -m ruff format --check .
+
+cd ../frontend
+npm run format:check
+
+cd ..
+git ls-files 'embedded/ssccam/**/*.c' 'embedded/ssccam/**/*.h' \
+ | xargs clang-format --dry-run -Werror
+```
\ No newline at end of file
diff --git a/embedded/ssccam/.clang-format b/embedded/ssccam/.clang-format
new file mode 100644
index 0000000..5292dab
--- /dev/null
+++ b/embedded/ssccam/.clang-format
@@ -0,0 +1,10 @@
+BasedOnStyle: LLVM
+IndentWidth: 4
+TabWidth: 4
+UseTab: Never
+ColumnLimit: 100
+BreakBeforeBraces: Attach
+AllowShortIfStatementsOnASingleLine: false
+AllowShortLoopsOnASingleLine: false
+AllowShortFunctionsOnASingleLine: Empty
+SortIncludes: false
diff --git a/embedded/ssccam/main/camera_manager.c b/embedded/ssccam/main/camera_manager.c
index 15909e8..cb3fbbf 100644
--- a/embedded/ssccam/main/camera_manager.c
+++ b/embedded/ssccam/main/camera_manager.c
@@ -43,10 +43,10 @@ esp_err_t camera_manager_init(void) {
.xclk_freq_hz = 20000000,
.ledc_timer = LEDC_TIMER_0,
.ledc_channel = LEDC_CHANNEL_0,
- .pixel_format = PIXFORMAT_JPEG,
- .frame_size = FRAMESIZE_VGA,
- .jpeg_quality = 12,
- .fb_count = 2,
+ .pixel_format = PIXFORMAT_JPEG,
+ .frame_size = FRAMESIZE_VGA,
+ .jpeg_quality = 12,
+ .fb_count = 2,
.grab_mode = CAMERA_GRAB_LATEST,
};
@@ -55,7 +55,7 @@ esp_err_t camera_manager_init(void) {
ESP_LOGE(TAG, "Camera Init Failed: 0x%x", err);
return err;
}
-
+
ESP_LOGI(TAG, "Camera Init Success");
return ESP_OK;
}
\ No newline at end of file
diff --git a/embedded/ssccam/main/main.c b/embedded/ssccam/main/main.c
index acb8d44..4a4216d 100644
--- a/embedded/ssccam/main/main.c
+++ b/embedded/ssccam/main/main.c
@@ -8,24 +8,24 @@
#include "esp_camera.h"
#include "esp_http_client.h"
-#include "wifi_manager.h" // Wi-Fi 매니저 헤더 포함
-#include "camera_manager.h" // 카메라 매니저 헤더 포함
+#include "wifi_manager.h" // Wi-Fi 매니저 헤더 포함
+#include "camera_manager.h" // 카메라 매니저 헤더 포함
#include "sdkconfig.h"
static const char *TAG = "SSCCam_Main";
-#define WIFI_SSID CONFIG_WIFI_SSID
-#define WIFI_PASS CONFIG_WIFI_PASSWORD
-#define SERVER_URL CONFIG_SERVER_URL
-#define COMMAND_URL CONFIG_COMMAND_URL
-#define MAXIMUM_RETRY 5
+#define WIFI_SSID CONFIG_WIFI_SSID
+#define WIFI_PASS CONFIG_WIFI_PASSWORD
+#define SERVER_URL CONFIG_SERVER_URL
+#define COMMAND_URL CONFIG_COMMAND_URL
+#define MAXIMUM_RETRY 5
// 서버로 이미지를 전송하는 함수
static esp_err_t send_image_to_server(camera_fb_t *fb) {
esp_http_client_config_t config = {
.url = SERVER_URL,
.method = HTTP_METHOD_POST,
- .timeout_ms = 5000,
+ .timeout_ms = 5000,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
@@ -34,11 +34,11 @@ static esp_err_t send_image_to_server(camera_fb_t *fb) {
snprintf(content_type, sizeof(content_type), "multipart/form-data; boundary=%s", boundary);
esp_http_client_set_header(client, "Content-Type", content_type);
- const char *body_start =
+ const char *body_start =
"------ESP32BoundarySSCCounter\r\n"
"Content-Disposition: form-data; name=\"file\"; filename=\"esp32_cam.jpg\"\r\n"
"Content-Type: image/jpeg\r\n\r\n";
-
+
const char *body_end = "\r\n------ESP32BoundarySSCCounter--\r\n";
int content_length = strlen(body_start) + fb->len + strlen(body_end);
@@ -59,8 +59,8 @@ void app_main(void) {
// 1. NVS 초기화
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
- ESP_ERROR_CHECK(nvs_flash_erase());
- ret = nvs_flash_init();
+ ESP_ERROR_CHECK(nvs_flash_erase());
+ ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
@@ -97,13 +97,13 @@ void app_main(void) {
// .timeout_ms = 2000,
// };
// esp_http_client_handle_t cmd_client = esp_http_client_init(&cmd_config);
-
+
// char response_buffer[128] = {0};
// esp_err_t err = esp_http_client_open(cmd_client, 0);
// if (err == ESP_OK) {
// esp_http_client_fetch_headers(cmd_client);
// esp_http_client_read(cmd_client, response_buffer, sizeof(response_buffer));
-
+
// // 서버 응답에 "capture"라는 단어가 있으면 사진 촬영 및 전송!
// if (strstr(response_buffer, "capture") != NULL) {
// ESP_LOGI(TAG, "Capture command received from server!");
diff --git a/embedded/ssccam/main/wifi_manager.c b/embedded/ssccam/main/wifi_manager.c
index fb7dfd4..a60369d 100644
--- a/embedded/ssccam/main/wifi_manager.c
+++ b/embedded/ssccam/main/wifi_manager.c
@@ -12,13 +12,14 @@ static const char *TAG = "WiFi_Mgr";
static EventGroupHandle_t s_wifi_event_group;
#define WIFI_CONNECTED_BIT BIT0
-#define WIFI_FAIL_BIT BIT1
+#define WIFI_FAIL_BIT BIT1
static int s_retry_num = 0;
static int s_max_retry = 0;
// Wi-Fi 이벤트 핸들러
-static void event_handler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) {
+static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id,
+ void *event_data) {
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
@@ -31,7 +32,7 @@ static void event_handler(void* arg, esp_event_base_t event_base, int32_t event_
}
ESP_LOGI(TAG, "Connect to the AP fail");
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
- ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
+ ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
s_retry_num = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
@@ -52,13 +53,16 @@ esp_err_t wifi_init_sta(const char *ssid, const char *pass, int max_retry) {
esp_event_handler_instance_t instance_any_id;
esp_event_handler_instance_t instance_got_ip;
- ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL, &instance_any_id));
- ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, &instance_got_ip));
+ ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
+ &event_handler, NULL, &instance_any_id));
+ ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
+ &event_handler, NULL, &instance_got_ip));
wifi_config_t wifi_config = {
- .sta = {
- .threshold.authmode = WIFI_AUTH_WPA2_PSK,
- },
+ .sta =
+ {
+ .threshold.authmode = WIFI_AUTH_WPA2_PSK,
+ },
};
// 매개변수로 받은 SSID와 Pass를 구조체에 복사
strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid));
@@ -70,7 +74,8 @@ esp_err_t wifi_init_sta(const char *ssid, const char *pass, int max_retry) {
ESP_LOGI(TAG, "wifi_init_sta finished.");
- EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY);
+ EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
+ pdFALSE, pdFALSE, portMAX_DELAY);
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "Connected to AP SSID:%s", ssid);
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
new file mode 100644
index 0000000..ee3a689
--- /dev/null
+++ b/frontend/.prettierignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+build/
+coverage/
diff --git a/frontend/.prettierrc b/frontend/.prettierrc
new file mode 100644
index 0000000..8d40c54
--- /dev/null
+++ b/frontend/.prettierrc
@@ -0,0 +1,10 @@
+{
+ "printWidth": 100,
+ "tabWidth": 2,
+ "useTabs": false,
+ "singleQuote": true,
+ "semi": true,
+ "trailingComma": "es5",
+ "bracketSpacing": true,
+ "htmlWhitespaceSensitivity": "css"
+}
diff --git a/frontend/css/base.css b/frontend/css/base.css
index fdb75ce..f43f9e8 100644
--- a/frontend/css/base.css
+++ b/frontend/css/base.css
@@ -1,33 +1,75 @@
/* base.css */
-* { margin: 0; padding: 0; box-sizing: border-box; }
-body { font-family: 'Inter', sans-serif; }
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+body {
+ font-family: 'Inter', sans-serif;
+}
-::-webkit-scrollbar { width: 6px; }
-::-webkit-scrollbar-track { background: #f1f5f9; }
-::-webkit-scrollbar-thumb { background: #c7d2fe; border-radius: 3px; }
-::-webkit-scrollbar-thumb:hover { background: #a5b4fc; }
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: #f1f5f9;
+}
+::-webkit-scrollbar-thumb {
+ background: #c7d2fe;
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #a5b4fc;
+}
-.page { display: none; }
-.page.active { display: block; }
+.page {
+ display: none;
+}
+.page.active {
+ display: block;
+}
/* Animations */
@keyframes pulseRing {
- 0% { transform: scale(1); opacity: 0.6; }
- 100% { transform: scale(1.8); opacity: 0; }
+ 0% {
+ transform: scale(1);
+ opacity: 0.6;
+ }
+ 100% {
+ transform: scale(1.8);
+ opacity: 0;
+ }
}
@keyframes pulseDot {
- 0% { transform: scale(1); }
- 50% { transform: scale(1.1); }
- 100% { transform: scale(1); }
+ 0% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.1);
+ }
+ 100% {
+ transform: scale(1);
+ }
}
@keyframes fadeIn {
- from { opacity: 0; transform: translateY(12px); }
- to { opacity: 1; transform: translateY(0); }
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
@keyframes statusBlink {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.5; }
-}
\ No newline at end of file
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
diff --git a/frontend/css/components.css b/frontend/css/components.css
index 3f6fd76..d183bd8 100644
--- a/frontend/css/components.css
+++ b/frontend/css/components.css
@@ -10,13 +10,23 @@
filter: drop-shadow(0 4px 20px rgba(99, 102, 241, 0.3));
}
-.pulse-ring { animation: pulseRing 2s ease-out infinite; }
-.pulse-dot { animation: pulseDot 2s ease-out infinite; }
-.fade-in { animation: fadeIn 0.4s ease-out; }
-.status-badge { animation: statusBlink 3s ease-in-out infinite; }
+.pulse-ring {
+ animation: pulseRing 2s ease-out infinite;
+}
+.pulse-dot {
+ animation: pulseDot 2s ease-out infinite;
+}
+.fade-in {
+ animation: fadeIn 0.4s ease-out;
+}
+.status-badge {
+ animation: statusBlink 3s ease-in-out infinite;
+}
.card-hover {
- transition: transform 0.25s ease, box-shadow 0.25s ease;
+ transition:
+ transform 0.25s ease,
+ box-shadow 0.25s ease;
}
.card-hover:hover {
transform: translateY(-4px);
@@ -28,5 +38,9 @@
border-bottom: 2px solid #6366f1;
}
-#dark-toggle { transition: transform 0.3s ease; }
-#dark-toggle:active { transform: rotate(25deg); }
\ No newline at end of file
+#dark-toggle {
+ transition: transform 0.3s ease;
+}
+#dark-toggle:active {
+ transform: rotate(25deg);
+}
diff --git a/frontend/css/darkmode.css b/frontend/css/darkmode.css
index 2cd5c3f..bdd810c 100644
--- a/frontend/css/darkmode.css
+++ b/frontend/css/darkmode.css
@@ -1,57 +1,148 @@
/* darkmode.css */
-.dark body { background-color: #0f172a; }
-.dark { color-scheme: dark; }
+.dark body {
+ background-color: #0f172a;
+}
+.dark {
+ color-scheme: dark;
+}
-.dark header { background: rgba(15, 23, 42, 0.85); border-bottom-color: #1e293b; }
+.dark header {
+ background: rgba(15, 23, 42, 0.85);
+ border-bottom-color: #1e293b;
+}
.dark .counter-number {
background: linear-gradient(135deg, #818cf8, #a78bfa, #c4b5fd);
-webkit-background-clip: text;
background-clip: text;
filter: drop-shadow(0 4px 30px rgba(129, 140, 248, 0.4));
}
-.dark .pulse-ring { background: rgba(99, 102, 241, 0.15); }
+.dark .pulse-ring {
+ background: rgba(99, 102, 241, 0.15);
+}
/* Tailwind overriding in dark mode */
-.dark .bg-white { background-color: #1e293b !important; }
-.dark .bg-gray-50 { background-color: #0f172a !important; }
-.dark .border-gray-100, .dark .border-gray-200 { border-color: #334155 !important; }
-.dark .text-gray-900 { color: #f1f5f9 !important; }
-.dark .text-gray-800 { color: #e2e8f0 !important; }
-.dark .text-gray-500, .dark .text-gray-600 { color: #94a3b8 !important; }
-.dark .text-gray-400 { color: #64748b !important; }
+.dark .bg-white {
+ background-color: #1e293b !important;
+}
+.dark .bg-gray-50 {
+ background-color: #0f172a !important;
+}
+.dark .border-gray-100,
+.dark .border-gray-200 {
+ border-color: #334155 !important;
+}
+.dark .text-gray-900 {
+ color: #f1f5f9 !important;
+}
+.dark .text-gray-800 {
+ color: #e2e8f0 !important;
+}
+.dark .text-gray-500,
+.dark .text-gray-600 {
+ color: #94a3b8 !important;
+}
+.dark .text-gray-400 {
+ color: #64748b !important;
+}
/* Custom Background colors */
-.dark .bg-blue-50 { background-color: rgba(59, 130, 246, 0.15) !important; }
-.dark .bg-amber-50 { background-color: rgba(245, 158, 11, 0.15) !important; }
-.dark .bg-emerald-50 { background-color: rgba(16, 185, 129, 0.15) !important; }
-.dark .bg-violet-50 { background-color: rgba(139, 92, 246, 0.15) !important; }
-.dark .bg-brand-50 { background-color: rgba(99, 102, 241, 0.15) !important; }
+.dark .bg-blue-50 {
+ background-color: rgba(59, 130, 246, 0.15) !important;
+}
+.dark .bg-amber-50 {
+ background-color: rgba(245, 158, 11, 0.15) !important;
+}
+.dark .bg-emerald-50 {
+ background-color: rgba(16, 185, 129, 0.15) !important;
+}
+.dark .bg-violet-50 {
+ background-color: rgba(139, 92, 246, 0.15) !important;
+}
+.dark .bg-brand-50 {
+ background-color: rgba(99, 102, 241, 0.15) !important;
+}
/* Shadow overriding */
-.dark .shadow-xl { box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important; }
-.dark .shadow-sm { box-shadow: none !important; }
-.dark .shadow-lg { box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3) !important; }
-.dark .card-hover:hover { box-shadow: 0 12px 40px rgba(99, 102, 241, 0.2); }
+.dark .shadow-xl {
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important;
+}
+.dark .shadow-sm {
+ box-shadow: none !important;
+}
+.dark .shadow-lg {
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3) !important;
+}
+.dark .card-hover:hover {
+ box-shadow: 0 12px 40px rgba(99, 102, 241, 0.2);
+}
/* Custom Text and Border Colors */
-.dark .nav-btn.active { color: #818cf8; border-bottom-color: #818cf8; }
-.dark .nav-btn { color: #64748b; }
-.dark .day-btn:not(.bg-brand-600) { background-color: #1e293b !important; color: #94a3b8 !important; border-color: #334155 !important; }
-.dark footer { background-color: #1e293b !important; border-top-color: #334155 !important; }
-.dark #current-time { background: rgba(255,255,255,0.08); }
-.dark .dev-card-header { background: linear-gradient(to bottom right, #4338ca, #3730a3) !important; }
-.dark .home-hero-gradient { background: linear-gradient(to bottom right, #4338ca, #3730a3, #312e81) !important; }
-.dark .text-emerald-600, .dark .text-emerald-500 { color: #34d399 !important; }
-.dark .text-brand-600 { color: #a5b4fc !important; }
-.dark .text-brand-700, .dark .text-brand-200 { color: #c7d2fe !important; }
-.dark .text-blue-500 { color: #60a5fa !important; }
-.dark .text-amber-500 { color: #fbbf24 !important; }
-.dark .text-violet-500 { color: #a78bfa !important; }
-.dark .text-brand-500 { color: #818cf8 !important; }
-.dark .text-brand-100 { color: #e0e7ff !important; }
-.dark .bg-white\/10 { background: rgba(255,255,255,0.06) !important; }
-.dark .bg-white\/80 { background: rgba(15, 23, 42, 0.85) !important; }
-.dark .border-white { border-color: #e2e8f0 !important; }
-.dark ::-webkit-scrollbar-track { background: #1e293b; }
-.dark ::-webkit-scrollbar-thumb { background: #4338ca; }
-.dark ::-webkit-scrollbar-thumb:hover { background: #6366f1; }
\ No newline at end of file
+.dark .nav-btn.active {
+ color: #818cf8;
+ border-bottom-color: #818cf8;
+}
+.dark .nav-btn {
+ color: #64748b;
+}
+.dark .day-btn:not(.bg-brand-600) {
+ background-color: #1e293b !important;
+ color: #94a3b8 !important;
+ border-color: #334155 !important;
+}
+.dark footer {
+ background-color: #1e293b !important;
+ border-top-color: #334155 !important;
+}
+.dark #current-time {
+ background: rgba(255, 255, 255, 0.08);
+}
+.dark .dev-card-header {
+ background: linear-gradient(to bottom right, #4338ca, #3730a3) !important;
+}
+.dark .home-hero-gradient {
+ background: linear-gradient(to bottom right, #4338ca, #3730a3, #312e81) !important;
+}
+.dark .text-emerald-600,
+.dark .text-emerald-500 {
+ color: #34d399 !important;
+}
+.dark .text-brand-600 {
+ color: #a5b4fc !important;
+}
+.dark .text-brand-700,
+.dark .text-brand-200 {
+ color: #c7d2fe !important;
+}
+.dark .text-blue-500 {
+ color: #60a5fa !important;
+}
+.dark .text-amber-500 {
+ color: #fbbf24 !important;
+}
+.dark .text-violet-500 {
+ color: #a78bfa !important;
+}
+.dark .text-brand-500 {
+ color: #818cf8 !important;
+}
+.dark .text-brand-100 {
+ color: #e0e7ff !important;
+}
+.dark .bg-white\/10 {
+ background: rgba(255, 255, 255, 0.06) !important;
+}
+.dark .bg-white\/80 {
+ background: rgba(15, 23, 42, 0.85) !important;
+}
+.dark .border-white {
+ border-color: #e2e8f0 !important;
+}
+.dark ::-webkit-scrollbar-track {
+ background: #1e293b;
+}
+.dark ::-webkit-scrollbar-thumb {
+ background: #4338ca;
+}
+.dark ::-webkit-scrollbar-thumb:hover {
+ background: #6366f1;
+}
diff --git a/frontend/data/developers.json b/frontend/data/developers.json
index 390d6f5..329d84a 100644
--- a/frontend/data/developers.json
+++ b/frontend/data/developers.json
@@ -74,4 +74,4 @@
}
]
}
-]
\ No newline at end of file
+]
diff --git a/frontend/index.html b/frontend/index.html
index 522d292..e11dd94 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,222 +1,332 @@
-
+
-
-
-
- SSCCounter
+
+
+
+ SSCCounter
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
0
-
명
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
시간대별 통계
-
동아리방 이용 패턴을 한눈에 확인하세요
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
시간대별 통계
+
동아리방 이용 패턴을 한눈에 확인하세요
+
-
-
-
시간대별 평균 인원
-
최근 4주 평균 기준
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
시간대별 평균 인원
+
최근 4주 평균 기준
+
+
-
0
-
최고 시간대 평균
-
-
-
-
-
-
-
-
개발자 정보
-
SSCCounter를 만든 사람들
+
+
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+