mirror of
https://github.com/skidoodle/ncore-stats.git
synced 2026-04-28 15:57:37 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e78ac9bf10 | |||
|
a4d01e3700
|
|||
|
355274b918
|
|||
|
c068755d4a
|
|||
|
a9216c116b
|
@@ -1,3 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
dist/
|
dist/
|
||||||
|
users.txt
|
||||||
|
|||||||
+2
-1
@@ -21,7 +21,8 @@ builds:
|
|||||||
- -trimpath
|
- -trimpath
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- formats:
|
||||||
|
- tar.gz
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .ProjectName }}_
|
{{ .ProjectName }}_
|
||||||
{{- .Version }}_
|
{{- .Version }}_
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
FROM golang:1.26.1-alpine AS builder
|
FROM golang:1.26.1-alpine AS builder
|
||||||
|
|
||||||
RUN apk update && apk add --no-cache git ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
RUN addgroup -S -g 10001 appgroup && \
|
RUN addgroup -S -g 10001 appgroup && \
|
||||||
adduser -S -u 10001 -G appgroup appuser
|
adduser -S -u 10001 -G appgroup appuser
|
||||||
@@ -20,6 +20,7 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
|||||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
COPY --from=builder /etc/passwd /etc/passwd
|
COPY --from=builder /etc/passwd /etc/passwd
|
||||||
COPY --from=builder /etc/group /etc/group
|
COPY --from=builder /etc/group /etc/group
|
||||||
|
|
||||||
COPY --from=builder --chown=10001:10001 /build/ncore-stats /app/ncore-stats
|
COPY --from=builder --chown=10001:10001 /build/ncore-stats /app/ncore-stats
|
||||||
COPY --from=builder --chown=10001:10001 /build/web /app/web
|
COPY --from=builder --chown=10001:10001 /build/web /app/web
|
||||||
COPY --from=builder --chown=10001:10001 /app/data /app/data
|
COPY --from=builder --chown=10001:10001 /app/data /app/data
|
||||||
|
|||||||
+11
-4
@@ -1,15 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
ncore-stats:
|
ncore-stats:
|
||||||
build: .
|
build: .
|
||||||
container_name: ncore-stats
|
container_name: ncore-stats-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
user: "1000:1000"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- data:/app/data
|
- ./data:/app/data
|
||||||
|
configs:
|
||||||
|
- source: users_config
|
||||||
|
target: /app/users.txt
|
||||||
environment:
|
environment:
|
||||||
- NICK=${NICK}
|
- NICK=${NICK}
|
||||||
- PASS=${PASS}
|
- PASS=${PASS}
|
||||||
|
|
||||||
volumes:
|
configs:
|
||||||
data:
|
users_config:
|
||||||
|
content: |
|
||||||
|
alice:123
|
||||||
|
bob:456
|
||||||
|
|||||||
+11
-1
@@ -3,13 +3,23 @@ services:
|
|||||||
image: ghcr.io/skidoodle/ncore-stats:latest
|
image: ghcr.io/skidoodle/ncore-stats:latest
|
||||||
container_name: ncore-stats
|
container_name: ncore-stats
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
user: "1000:1000"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- data:/app/data
|
- data:/app/data
|
||||||
|
configs:
|
||||||
|
- source: users_config
|
||||||
|
target: /app/users.txt
|
||||||
environment:
|
environment:
|
||||||
- NICK=${NICK}
|
- NICK=${NICK}
|
||||||
- PASSWORD=${PASS}
|
- PASS=${PASS}
|
||||||
|
|
||||||
|
configs:
|
||||||
|
users_config:
|
||||||
|
content: |
|
||||||
|
alice:123
|
||||||
|
bob:456
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ func loadConfig() *Configuration {
|
|||||||
cfg.DatabasePath = defaultDbFolder
|
cfg.DatabasePath = defaultDbFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.UsersPath = os.Getenv("USERS_PATH")
|
||||||
|
if cfg.UsersPath == "" {
|
||||||
|
cfg.UsersPath = "./users.txt"
|
||||||
|
}
|
||||||
|
|
||||||
lvl, _ := logrus.ParseLevel(os.Getenv("LOG_LEVEL"))
|
lvl, _ := logrus.ParseLevel(os.Getenv("LOG_LEVEL"))
|
||||||
if lvl == 0 {
|
if lvl == 0 {
|
||||||
lvl = logrus.InfoLevel
|
lvl = logrus.InfoLevel
|
||||||
|
|||||||
+94
-23
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -18,7 +19,12 @@ func initDB(cfg *Configuration) *sql.DB {
|
|||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
schemas := []string{
|
schemas := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT UNIQUE, profile_id TEXT);`,
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
display_name TEXT UNIQUE,
|
||||||
|
profile_id TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`,
|
||||||
`CREATE TABLE IF NOT EXISTS profile_history (
|
`CREATE TABLE IF NOT EXISTS profile_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER,
|
user_id INTEGER,
|
||||||
@@ -46,26 +52,19 @@ func initDB(cfg *Configuration) *sql.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrate(db *sql.DB) {
|
func migrate(db *sql.DB) {
|
||||||
var columnExists bool
|
var ubExists bool
|
||||||
_ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('profile_history') WHERE name='upload_bytes'").Scan(&columnExists)
|
_ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('profile_history') WHERE name='upload_bytes'").Scan(&ubExists)
|
||||||
if !columnExists {
|
if !ubExists {
|
||||||
logrus.Info("Migrating: Adding upload_bytes column...")
|
logrus.Info("Migrating: Adding upload_bytes column...")
|
||||||
_, err := db.Exec("ALTER TABLE profile_history ADD COLUMN upload_bytes INTEGER")
|
_, _ = db.Exec("ALTER TABLE profile_history ADD COLUMN upload_bytes INTEGER")
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("Migration failed (add column): %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Info("Migrating: Backfilling upload_bytes from existing strings...")
|
|
||||||
|
|
||||||
type updateRow struct {
|
type updateRow struct {
|
||||||
id int64
|
id int64
|
||||||
bytes int64
|
bytes int64
|
||||||
}
|
}
|
||||||
var updates []updateRow
|
var updates []updateRow
|
||||||
|
rows, _ := db.Query("SELECT id, upload FROM profile_history WHERE upload_bytes IS NULL")
|
||||||
rows, err := db.Query("SELECT id, upload FROM profile_history WHERE upload_bytes IS NULL")
|
if rows != nil {
|
||||||
if err == nil {
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var id int64
|
||||||
var upload string
|
var upload string
|
||||||
@@ -75,24 +74,96 @@ func migrate(db *sql.DB) {
|
|||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
tx, err := db.Begin()
|
tx, _ := db.Begin()
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("Transaction start failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stmt, _ := tx.Prepare("UPDATE profile_history SET upload_bytes = ? WHERE id = ?")
|
stmt, _ := tx.Prepare("UPDATE profile_history SET upload_bytes = ? WHERE id = ?")
|
||||||
for _, up := range updates {
|
for _, up := range updates {
|
||||||
_, _ = stmt.Exec(up.bytes, up.id)
|
_, _ = stmt.Exec(up.bytes, up.id)
|
||||||
}
|
}
|
||||||
stmt.Close()
|
stmt.Close()
|
||||||
if err := tx.Commit(); err != nil {
|
_ = tx.Commit()
|
||||||
logrus.Errorf("Transaction commit failed: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logrus.Info("Migration complete.")
|
var caExists bool
|
||||||
|
_ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='created_at'").Scan(&caExists)
|
||||||
|
if !caExists {
|
||||||
|
logrus.Info("Migrating: Adding created_at column to users...")
|
||||||
|
_, err := db.Exec("ALTER TABLE users ADD COLUMN created_at DATETIME")
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("User migration failed (add column): %v", err)
|
||||||
|
} else {
|
||||||
|
_, _ = db.Exec("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||||
|
logrus.Info("User migration complete.")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) syncUsers() {
|
||||||
|
if _, err := os.Stat(s.config.UsersPath); os.IsNotExist(err) {
|
||||||
|
logrus.Warnf("Users config file not found at %s, skipping sync", s.config.UsersPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(s.config.UsersPath)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("Failed to read users file: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type userEntry struct {
|
||||||
|
Name string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
var users []userEntry
|
||||||
|
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
users = append(users, userEntry{
|
||||||
|
Name: strings.TrimSpace(parts[0]),
|
||||||
|
ID: strings.TrimSpace(parts[1]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range users {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO users (display_name, profile_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(display_name) DO UPDATE SET profile_id = excluded.profile_id`,
|
||||||
|
u.Name, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("Sync failed for %s: %v", u.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := s.db.Query("SELECT display_name FROM users")
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
rows.Scan(&name)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Name == name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
logrus.Infof("Sync: Removing %s (not in config)", name)
|
||||||
|
_, _ = s.db.Exec("DELETE FROM users WHERE display_name = ?", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logrus.Infof("User synchronization complete (%d users)", len(users))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) getLatest() ([]ProfileData, error) {
|
func (s *State) getLatest() ([]ProfileData, error) {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func (s *State) historyModalHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
x-init='renderChart(%s)'>
|
x-init='renderChart(%s)'>
|
||||||
</div>`, string(dataJSON))
|
</div>`, string(dataJSON))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) rootHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *State) rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ func main() {
|
|||||||
client: &http.Client{Timeout: 45 * time.Second},
|
client: &http.Client{Timeout: 45 * time.Second},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.syncUsers()
|
||||||
|
|
||||||
if handleFlags(state) {
|
if handleFlags(state) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
ServerPort string
|
ServerPort string
|
||||||
DatabasePath string
|
DatabasePath string
|
||||||
|
UsersPath string
|
||||||
LogLevel logrus.Level
|
LogLevel logrus.Level
|
||||||
Ncore struct {
|
Ncore struct {
|
||||||
Nick string
|
Nick string
|
||||||
|
|||||||
@@ -1,59 +1,19 @@
|
|||||||
# nCore Profile Tracker
|
# nCore Stats
|
||||||
|
|
||||||
A simple Go project to scrape and track profile statistics (rank, upload, download, points) on nCore, the largest Hungarian BitTorrent tracker. The stats are stored in a SQLite database and displayed on a basic web interface.
|
A tracker for nCore profile statistics.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Quick Start
|
||||||
|
|
||||||
- Scrapes and logs profile stats from nCore for multiple users.
|
1. Create a `.env` file with your credentials:
|
||||||
- Serves a simple HTML dashboard to display the latest data and historical charts.
|
|
||||||
- Provides a JSON API to fetch historical profile data.
|
|
||||||
- Stores all data persistently in a SQLite database.
|
|
||||||
- Automatically updates data every 24 hours.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. **Clone the repo:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/skidoodle/ncore-stats
|
|
||||||
cd ncore-stats
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create a `.env` file with your nCore credentials:**
|
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
NICK=your_nick
|
NICK=your_nick
|
||||||
PASS=your_password
|
PASS=your_password
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Add Users to Track**
|
2. Save this `compose.yaml`:
|
||||||
|
|
||||||
You can add users via the `--add-user` command-line flag. Run this command for each user you want to track.
|
|
||||||
|
|
||||||
The format is a single string: `'DisplayName,ProfileID'`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example for running from source
|
|
||||||
go run . --add-user 'Alice,69'
|
|
||||||
go run . --add-user 'Bob,420'
|
|
||||||
```
|
|
||||||
|
|
||||||
> **How to find a Profile ID?**
|
|
||||||
> Navigate to a user's profile on nCore. The URL will be `https://ncore.pro/profile.php?id=12345`. The `ProfileID` is the number at the end.
|
|
||||||
|
|
||||||
### How to obtain `NICK` and `PASS`
|
|
||||||
|
|
||||||
- Open the developer tools in your browser (F12), go to the "Network" tab.
|
|
||||||
- Log in to nCore using "lower security" mode.
|
|
||||||
- Find the `login.php` request in the network activity.
|
|
||||||
- In the response headers, locate the `Set-Cookie` header, which will contain `nick=` and `pass=` values.
|
|
||||||
- Copy those values and add them to your `.env` file.
|
|
||||||
|
|
||||||
## Running with Docker Compose
|
|
||||||
|
|
||||||
1. **Create the following `docker-compose.yml` file:**
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -61,38 +21,33 @@ A simple Go project to scrape and track profile statistics (rank, upload, downlo
|
|||||||
image: ghcr.io/skidoodle/ncore-stats:latest
|
image: ghcr.io/skidoodle/ncore-stats:latest
|
||||||
container_name: ncore-stats
|
container_name: ncore-stats
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
user: "1000:1000"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- data:/app/data
|
||||||
|
configs:
|
||||||
|
- source: users_config
|
||||||
|
target: /app/users.txt
|
||||||
environment:
|
environment:
|
||||||
- NICK=${NICK}
|
- NICK=${NICK}
|
||||||
- PASS=${PASS}
|
- PASS=${PASS}
|
||||||
|
|
||||||
|
configs:
|
||||||
|
users_config:
|
||||||
|
content: |
|
||||||
|
alice:123
|
||||||
|
bob:456
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Add Users using Docker**
|
3. Run `docker compose up -d`.
|
||||||
|
|
||||||
When the container is already running, you can use `docker exec`:
|
### How to get NICK and PASS
|
||||||
```bash
|
|
||||||
# The executable inside the container is named 'ncore-stats'
|
|
||||||
docker exec ncore-stats ./ncore-stats --add-user 'Charlie,1337'
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Run the Docker Compose setup:**
|
1. Log in to nCore in your browser using **"lower security"** mode.
|
||||||
|
2. Open Developer Tools (F12) and go to the **Network** tab.
|
||||||
Once you have added your users, start the service.
|
3. Refresh, find any request to `ncore.pro`.
|
||||||
|
4. Check the **Cookie** request header for `nick=...; pass=...`.
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Open `http://localhost:3000` to view your stats.
|
|
||||||
|
|
||||||
### Updating
|
|
||||||
|
|
||||||
To pull the latest image and restart the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|||||||
+2
-2
@@ -130,8 +130,8 @@ func (s *State) fetchProfile(ctx context.Context, user User) (*ProfileData, erro
|
|||||||
})
|
})
|
||||||
|
|
||||||
doc.Find(".lista_mini_fej").Each(func(i int, sel *goquery.Selection) {
|
doc.Find(".lista_mini_fej").Each(func(i int, sel *goquery.Selection) {
|
||||||
text := sel.Text()
|
text := strings.ToLower(sel.Text())
|
||||||
if strings.Contains(text, "seeding") || strings.Contains(text, "futó") {
|
if strings.Contains(text, "fel:") || strings.Contains(text, "le:") || strings.Contains(text, "seeding") || strings.Contains(text, "futó") {
|
||||||
if m := regexp.MustCompile(`\((\d+)\)`).FindStringSubmatch(text); len(m) > 1 {
|
if m := regexp.MustCompile(`\((\d+)\)`).FindStringSubmatch(text); len(m) > 1 {
|
||||||
p.SeedingCount, _ = strconv.Atoi(m[1])
|
p.SeedingCount, _ = strconv.Atoi(m[1])
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-4
@@ -33,7 +33,7 @@
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>{{.Owner}}</h3>
|
<h3>{{.Owner}}</h3>
|
||||||
<span class="rank-badge">RANK #{{.Rank}}</span>
|
<span class="rank-badge">#{{.Rank}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-group">
|
<div class="stats-group">
|
||||||
@@ -66,9 +66,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div
|
<div class="empty-state">
|
||||||
style="grid-column: 1 / -1; padding: 4rem; text-align: center; color: var(--muted); border: 1px dashed var(--border);">
|
No profiles tracked. Mount users.txt to start.
|
||||||
No profiles found. Use the CLI to add users.
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+40
-50
@@ -22,13 +22,12 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
background-color: var(--bg);
|
background-color: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -36,23 +35,25 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 2rem;
|
padding: 2.5rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.75rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
@@ -62,20 +63,8 @@ h1 {
|
|||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.25em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
overflow-y: auto;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -84,7 +73,17 @@ main {
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 4rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
position: relative;
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -116,12 +115,12 @@ main {
|
|||||||
|
|
||||||
.rank-badge {
|
.rank-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 0.7rem;
|
font-size: 0.85rem;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
padding: 4px 10px;
|
padding: 6px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,23 +178,6 @@ main {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-range {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-range:hover {
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-container {
|
.spinner-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -290,19 +272,23 @@ main {
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
main::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
main::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
main::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.apexcharts-canvas,
|
.apexcharts-canvas,
|
||||||
.apexcharts-canvas *,
|
.apexcharts-canvas *,
|
||||||
.apexcharts-canvas *:focus {
|
.apexcharts-canvas *:focus {
|
||||||
@@ -316,11 +302,13 @@ main::-webkit-scrollbar-thumb {
|
|||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5) !important;
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 1.5rem;
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -328,7 +316,9 @@ main::-webkit-scrollbar-thumb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-panel {
|
.modal-panel {
|
||||||
width: 95vw;
|
width: 100vw;
|
||||||
height: 90vh;
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user