Compare commits
42 Commits
4416ce3280
...
galt
| Author | SHA1 | Date | |
|---|---|---|---|
| dff9d56e1b | |||
| 37d2ce99bf | |||
| b8e5a2841f | |||
| 232e553c02 | |||
| d936175de8 | |||
| 560693633e | |||
| 176032e54b | |||
| d9bbede328 | |||
| 390410020f | |||
| 7849a77acc | |||
| 03f92076b5 | |||
| 1a84e44351 | |||
| 975b72e89f | |||
| 91c9aba1ab | |||
| 1541f1ca85 | |||
| ab0b102a81 | |||
| fd24695764 | |||
| d13ea9fc27 | |||
| cf74cce51c | |||
| 08bf952988 | |||
| 63c6031d66 | |||
| 1b85072c3c | |||
| a25582e1a1 | |||
| 697cae5108 | |||
| 6ecf2fd98b | |||
| 0d604a1c5b | |||
| 372adaa8cf | |||
| 605dc7dd46 | |||
| b1209ca25d | |||
| 6778c448c2 | |||
| 55f07c96cd | |||
| 3436874998 | |||
| 9e00e4db6c | |||
| 05e8d039bd | |||
| 52aab3460d | |||
| ab8bfc0b1c | |||
| 6d9e307318 | |||
| 35c4317925 | |||
| 5392c47452 | |||
| 5348777ed4 | |||
| 20c8981268 | |||
| e0d22545fb |
33
.gitea/workflows/feedmee-test-backend-build-docker.yaml
Normal file
33
.gitea/workflows/feedmee-test-backend-build-docker.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Build Feedmee Go Backend App
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./backend
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{vars.REGISTRY_URL}}
|
||||||
|
username: ${{vars.ACTOR}}
|
||||||
|
password: ${{secrets.TOKEN}}
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build -f Dockerfile -t ${{vars.REGISTRY_URL}}/${{github.repository}}-backend-test:latest .
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push ${{vars.REGISTRY_URL}}/${{github.repository}}-backend-test:latest
|
||||||
33
.gitea/workflows/feedmee-test-frontend-build-docker.yaml
Normal file
33
.gitea/workflows/feedmee-test-frontend-build-docker.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Build Feedmee Next.js Frontend App
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./feedmee
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{vars.REGISTRY_URL}}
|
||||||
|
username: ${{vars.ACTOR}}
|
||||||
|
password: ${{secrets.TOKEN}}
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build -f Dockerfile -t ${{vars.REGISTRY_URL}}/${{github.repository}}-frontend-test:latest .
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push ${{vars.REGISTRY_URL}}/${{github.repository}}-frontend-test:latest
|
||||||
33
.gitea/workflows/galt-test-frontend-build.yaml
Normal file
33
.gitea/workflows/galt-test-frontend-build.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Build Feedmee Next.js Frontend App
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./feedmee
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- galt
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{vars.REGISTRY_URL}}
|
||||||
|
username: ${{vars.ACTOR}}
|
||||||
|
password: ${{secrets.TOKEN}}
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build -f Dockerfile -t ${{vars.REGISTRY_URL}}/${{github.repository}}-frontend-galt-test:latest .
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push ${{vars.REGISTRY_URL}}/${{github.repository}}-frontend-galt-test:latest
|
||||||
33
.gitea/workflows/gat-test-backend-build.yaml
Normal file
33
.gitea/workflows/gat-test-backend-build.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Build Feedmee Go Backend App
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./backend
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- galt
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{vars.REGISTRY_URL}}
|
||||||
|
username: ${{vars.ACTOR}}
|
||||||
|
password: ${{secrets.TOKEN}}
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build -f Dockerfile -t ${{vars.REGISTRY_URL}}/${{github.repository}}-backend-galt-test:latest .
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push ${{vars.REGISTRY_URL}}/${{github.repository}}-backend-galt-test:latest
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
6
backend/.gitignore
vendored
Normal file
6
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
go.work*
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
33
backend/Dockerfile
Normal file
33
backend/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# --- BUILD STAGE ---
|
||||||
|
FROM golang:1.25.3 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Fontos: CGO_ENABLED=0 a statikusan linkelt binárisért, ami kompatibilis lesz az Alpine alapú futtatási környezettel.
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags="-s -w -X 'main.BuildTime=$(date)'" -o backend .
|
||||||
|
|
||||||
|
|
||||||
|
# --- DEPLOY STAGE ---
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
WORKDIR /app/
|
||||||
|
COPY --from=builder /app/backend .
|
||||||
|
|
||||||
|
# Ha a Go alkalmazásodnak szüksége van SSL tanúsítványokra (pl. HTTPS hívásokhoz külső API-k felé), akkor az Alpine image-be telepíteni kell a ca-certificates csomagot.
|
||||||
|
# A CGO_ENABLED=0 miatt elvileg a Go beépített gyökér tanúsítványtárát használja, de biztonságosabb a rendszer ca-certificates csomagját is felrakni.
|
||||||
|
# Ezt csak akkor tedd meg, ha szükséges, és csak a futtatási stage-ben!
|
||||||
|
# RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
RUN cp /usr/share/zoneinfo/Europe/Budapest /etc/localtime
|
||||||
|
RUN echo "Europe/Budapest" > /etc/timezone
|
||||||
|
|
||||||
|
ENV TZ="Europe/Budapest"
|
||||||
|
ENV LANG="hu_HU.UTF-8"
|
||||||
|
ENV LANGUAGE="hu_HU.UTF-8"
|
||||||
|
ENV LC_ALL="hu_HU.UTF-8"
|
||||||
|
|
||||||
|
EXPOSE 7153
|
||||||
|
CMD ["./backend"]
|
||||||
BIN
backend/app.db
BIN
backend/app.db
Binary file not shown.
174
backend/db.go
174
backend/db.go
@@ -2,24 +2,83 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"embed"
|
||||||
"strings"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func database() *sql.DB {
|
//go:embed migrations/*.sql
|
||||||
db, err := sql.Open("sqlite", "./app.db")
|
var embedMigrations embed.FS
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
// Create new sqlite database
|
||||||
|
// If filepath parameter is empty
|
||||||
|
func NewDatabaseConnection(dataDir string, dbName string) (*sql.DB, error) {
|
||||||
|
var dbFilePath string
|
||||||
|
|
||||||
|
if dbName == "" {
|
||||||
|
dbFilePath = filepath.Join(dataDir, "app.db")
|
||||||
|
} else {
|
||||||
|
dbFilePath = filepath.Join(dataDir, filepath.Base(dbName))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed default menu items if empty
|
db, err := sql.Open("sqlite", dbFilePath)
|
||||||
row := db.QueryRow("SELECT COUNT(*) FROM menu_items")
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("database open error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("database connection error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MigrateDatabase(db *sql.DB) {
|
||||||
|
dialect := "sqlite3"
|
||||||
|
goose.SetBaseFS(embedMigrations)
|
||||||
|
|
||||||
|
if err := goose.SetDialect(dialect); err != nil {
|
||||||
|
slog.Error("Database dialect error", "dialect", dialect, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := goose.Up(db, "migrations"); err != nil {
|
||||||
|
slog.Error("Database migration error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
migratedVersion, err := goose.GetDBVersion(db)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Datatabase migration version check error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Database up to date", "version", migratedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SeedDatabase(db *sql.DB, superadminPassword string) error {
|
||||||
|
seededValue := "false"
|
||||||
|
row := db.QueryRow("SELECT value FROM settings WHERE key='seeded';")
|
||||||
|
_ = row.Scan(&seededValue)
|
||||||
|
|
||||||
|
if seededValue == "true" {
|
||||||
|
slog.Info("Database already seeded with default data, skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Database empty, seeding with default values")
|
||||||
|
|
||||||
|
row = db.QueryRow("SELECT COUNT(*) FROM menu_items")
|
||||||
var count int
|
var count int
|
||||||
_ = row.Scan(&count)
|
_ = row.Scan(&count)
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
log.Println("Seeding default menu_items…")
|
slog.Info("Seeding default menu_items")
|
||||||
|
|
||||||
defaults := []struct {
|
defaults := []struct {
|
||||||
category string
|
category string
|
||||||
name string
|
name string
|
||||||
@@ -55,88 +114,33 @@ func database() *sql.DB {
|
|||||||
for _, d := range defaults {
|
for _, d := range defaults {
|
||||||
_, err := db.Exec("INSERT INTO menu_items (category, name) VALUES (?, ?)", d.category, d.name)
|
_, err := db.Exec("INSERT INTO menu_items (category, name) VALUES (?, ?)", d.category, d.name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Error seeding menu item:", err)
|
return fmt.Errorf("Error seeding menu_items table: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
slog.Info("Table menu_items already seeded, skipping")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create users table
|
_, err := db.Exec(`INSERT OR REPLACE INTO settings (key, value) VALUES ('seeded', 'true')`)
|
||||||
_, err = db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
password TEXT NOT NULL,
|
|
||||||
role INTEGER NOT NULL DEFAULT 2
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return fmt.Errorf("Error saving setting table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create settings table
|
updateSuperadminPassword(db, superadminPassword)
|
||||||
_, err = db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed default finalize_time if missing
|
return nil
|
||||||
_, err = db.Exec(`INSERT OR IGNORE INTO settings (key, value) VALUES ('finalize_time', '10:30')`)
|
}
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
func updateSuperadminPassword(db *sql.DB, superadminPassword string) {
|
||||||
}
|
_, err := db.Exec(`INSERT OR REPLACE INTO users (id, username, password, role)
|
||||||
|
VALUES (
|
||||||
_, err = db.Exec(`
|
1,
|
||||||
CREATE TABLE IF NOT EXISTS menu_items (
|
'superadmin',
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
?,
|
||||||
category TEXT NOT NULL,
|
COALESCE((SELECT role FROM users WHERE id = 1), '0')
|
||||||
name TEXT NOT NULL
|
)`, superadminPassword)
|
||||||
)
|
if err != nil {
|
||||||
`)
|
slog.Error("Error setting superadmin password", "error", err)
|
||||||
if err != nil {
|
}
|
||||||
log.Fatal(err)
|
slog.Debug("Superadmin password", "password", superadminPassword)
|
||||||
}
|
|
||||||
|
|
||||||
// Create selections table (latest choice per user)
|
|
||||||
_, err = db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS selections (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
main TEXT NOT NULL,
|
|
||||||
side TEXT NOT NULL,
|
|
||||||
soup TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create orders table (all placed orders for history + realtime updates)
|
|
||||||
_, err = db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS orders (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
soup TEXT,
|
|
||||||
main TEXT NOT NULL,
|
|
||||||
side TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add status column if it doesn't exist
|
|
||||||
_, err = db.Exec(`ALTER TABLE orders ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`)
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "duplicate column name") {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return db
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
module menu
|
module menu
|
||||||
|
|
||||||
go 1.25
|
go 1.25.3
|
||||||
|
|
||||||
require github.com/go-chi/chi/v5 v5.2.3
|
require github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
modernc.org/sqlite v1.39.0
|
modernc.org/sqlite v1.39.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/pressly/goose/v3 v3.26.0
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
|
|||||||
@@ -10,18 +10,28 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
|||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||||
|
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
|||||||
377
backend/handlers/api.go
Normal file
377
backend/handlers/api.go
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET current finalize time
|
||||||
|
func (app *App) HandleGetFinalizeTime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var t string
|
||||||
|
err := app.DB.QueryRow("SELECT value FROM settings WHERE key = 'finalize_time'").Scan(&t)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
t = "10:30" // default if not set
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"time": t})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle User Registration
|
||||||
|
func (app *App) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req registerRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := app.DB.Exec(
|
||||||
|
"INSERT INTO users (username, password, role) VALUES (?, ?, ?)",
|
||||||
|
req.Username, req.Password, 100,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "username already exists", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
token, err := generateToken(int(id), req.Username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{
|
||||||
|
"token": token,
|
||||||
|
"username": req.Username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle User Login
|
||||||
|
func (app *App) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int
|
||||||
|
var storedPassword string
|
||||||
|
err := app.DB.QueryRow("SELECT id, password FROM users WHERE username = ?", req.Username).Scan(&id, &storedPassword)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid username or password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if storedPassword != req.Password {
|
||||||
|
http.Error(w, "invalid username or password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateToken(id, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{
|
||||||
|
"token": token,
|
||||||
|
"username": req.Username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Menu Options from DB
|
||||||
|
func (app *App) HandleOptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := app.DB.Query("SELECT category, name FROM menu_items ORDER BY id ASC")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
valasztek := Valasztek{
|
||||||
|
Levesek: []Leves{},
|
||||||
|
Foetelek: []Foetel{},
|
||||||
|
Koretek: []Koret{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var category, name string
|
||||||
|
if err := rows.Scan(&category, &name); err == nil {
|
||||||
|
switch category {
|
||||||
|
case "soup":
|
||||||
|
valasztek.Levesek = append(valasztek.Levesek, name)
|
||||||
|
case "main":
|
||||||
|
valasztek.Foetelek = append(valasztek.Foetelek, name)
|
||||||
|
case "side":
|
||||||
|
valasztek.Koretek = append(valasztek.Koretek, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, valasztek)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle User Saved Selection
|
||||||
|
func (app *App) HandleSaveSelection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tokenStr := r.Header.Get("Authorization")
|
||||||
|
if tokenStr == "" {
|
||||||
|
http.Error(w, "missing token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip "Bearer " prefix
|
||||||
|
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
|
||||||
|
tokenStr = tokenStr[7:]
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return jwtSecret, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
|
userID := int(claims["user_id"].(float64))
|
||||||
|
|
||||||
|
var req selectionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
// Insert or update selection (simple approach: delete old one, insert new)
|
||||||
|
_, _ = app.DB.Exec("DELETE FROM selections WHERE user_id = ?", userID)
|
||||||
|
_, err = app.DB.Exec(
|
||||||
|
"INSERT INTO selections (user_id, main, side, soup, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
userID, req.Main, req.Side, req.Soup, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to save selection", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"main": req.Main,
|
||||||
|
"side": req.Side,
|
||||||
|
"soup": req.Soup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle User's selected Menu
|
||||||
|
func (app *App) HandleGetSelection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tokenStr := r.Header.Get("Authorization")
|
||||||
|
if tokenStr == "" {
|
||||||
|
http.Error(w, "missing token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
|
||||||
|
tokenStr = tokenStr[7:]
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return jwtSecret, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
|
userID := int(claims["user_id"].(float64))
|
||||||
|
|
||||||
|
row := app.DB.QueryRow("SELECT main, side, soup, created_at FROM selections WHERE user_id = ?", userID)
|
||||||
|
var main, side, soup, createdAt string
|
||||||
|
err = row.Scan(&main, &side, &soup, &createdAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeJSON(w, map[string]string{"status": "none"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{
|
||||||
|
"main": main,
|
||||||
|
"side": side,
|
||||||
|
"soup": soup,
|
||||||
|
"created_at": createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active Orders
|
||||||
|
func (app *App) HandleGetOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := app.DB.Query("SELECT id, username, soup, main, side, created_at, status FROM orders WHERE status = 'active' ORDER BY id DESC")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Order
|
||||||
|
for rows.Next() {
|
||||||
|
var o Order
|
||||||
|
if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new Order
|
||||||
|
func (app *App) HandleAddOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct{ Soup, Main, Side string }
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Main == "" || in.Side == "" {
|
||||||
|
http.Error(w, "invalid order", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, err := usernameFromJWT(r)
|
||||||
|
if err != nil || username == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
// // Step 1: mark any previous active orders for this user as history
|
||||||
|
// _, _ = app.DB.Exec("UPDATE orders SET status = 'history' WHERE username = ? AND status = 'active'", username)
|
||||||
|
|
||||||
|
// Step 2: insert new active order
|
||||||
|
res, err := app.DB.Exec(
|
||||||
|
"INSERT INTO orders (username, soup, main, side, created_at, status) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
username, in.Soup, in.Main, in.Side, now, "active",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
ord := Order{
|
||||||
|
ID: int(id),
|
||||||
|
Username: username,
|
||||||
|
Soup: in.Soup,
|
||||||
|
Main: in.Main,
|
||||||
|
Side: in.Side,
|
||||||
|
CreatedAt: now,
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast to SSE clients
|
||||||
|
if data, err := json.Marshal(ord); err == nil {
|
||||||
|
log.Printf("Broadcasting active order via SSE: %+v", ord)
|
||||||
|
app.Broker.broadcast <- data
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, ord)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Order deletion
|
||||||
|
func (app *App) HandleDeleteOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
username, err := usernameFromJWT(r)
|
||||||
|
if err != nil || username == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := app.DB.Exec("UPDATE orders SET status = 'history' WHERE id = ? AND username = ?", id, username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
http.Error(w, "order not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast deletion (tell clients to refresh)
|
||||||
|
msg := map[string]any{"id": id, "status": "history", "event": "deleted"}
|
||||||
|
if data, err := json.Marshal(msg); err == nil {
|
||||||
|
app.Broker.broadcast <- data
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "archived"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Orders over SSE to client
|
||||||
|
func (app *App) HandleOrdersStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Del("Content-Encoding")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "stream unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan []byte, 1)
|
||||||
|
app.Broker.add <- ch
|
||||||
|
defer func() { app.Broker.remove <- ch }()
|
||||||
|
|
||||||
|
// open the stream
|
||||||
|
w.Write([]byte(":ok\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
log.Println("SSE client disconnected")
|
||||||
|
return
|
||||||
|
case msg := <-ch:
|
||||||
|
log.Printf("SSE send: %s", string(msg))
|
||||||
|
w.Write([]byte("data: "))
|
||||||
|
w.Write(msg)
|
||||||
|
w.Write([]byte("\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
case <-ticker.C:
|
||||||
|
// log.Println("SSE heartbeat -> :ping")
|
||||||
|
w.Write([]byte(":ping\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get User's information and Role
|
||||||
|
func (app *App) HandleWhoAmI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, err := usernameFromJWT(r)
|
||||||
|
if err != nil || username == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := app.getUserRole(username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"username": username,
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
}
|
||||||
232
backend/handlers/api_admin.go
Normal file
232
backend/handlers/api_admin.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) HandleAdminMenu(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch in.Action {
|
||||||
|
case "add":
|
||||||
|
_, err := app.DB.Exec("INSERT INTO menu_items (category, name) VALUES (?, ?)", in.Category, in.Name)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "update":
|
||||||
|
_, err := app.DB.Exec("UPDATE menu_items SET name = ? WHERE id = ?", in.Name, in.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "delete":
|
||||||
|
_, err := app.DB.Exec("DELETE FROM menu_items WHERE id = ?", in.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return updated menu
|
||||||
|
rows, _ := app.DB.Query("SELECT id, category, name FROM menu_items ORDER BY id ASC")
|
||||||
|
defer rows.Close()
|
||||||
|
var items []map[string]any
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var category, name string
|
||||||
|
rows.Scan(&id, &category, &name)
|
||||||
|
items = append(items, map[string]any{"id": id, "category": category, "name": name})
|
||||||
|
}
|
||||||
|
writeJSON(w, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) HandleAdminUpdateOrderStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var in struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := app.DB.Exec("UPDATE orders SET status = ? WHERE id = ?", in.Status, id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch in.Status {
|
||||||
|
case "history":
|
||||||
|
// If status is history -> send deleted event
|
||||||
|
msg := map[string]any{"id": id, "status": "history", "event": "deleted"}
|
||||||
|
if data, err := json.Marshal(msg); err == nil {
|
||||||
|
app.Broker.broadcast <- data
|
||||||
|
}
|
||||||
|
case "active":
|
||||||
|
// If status is active -> send new order
|
||||||
|
var o Order
|
||||||
|
row := app.DB.QueryRow("SELECT id, username, soup, main, side, created_at, status FROM orders WHERE id = ?", id)
|
||||||
|
if err := row.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err == nil {
|
||||||
|
if data, err := json.Marshal(o); err == nil {
|
||||||
|
app.Broker.broadcast <- data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) HandleAdminDeleteOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Step 1: set to history (so user views update immediately)
|
||||||
|
_, _ = app.DB.Exec("UPDATE orders SET status = 'history' WHERE id = ?", id)
|
||||||
|
|
||||||
|
// Step 2: broadcast history update
|
||||||
|
msg := map[string]any{"id": id, "status": "history", "event": "deleted"}
|
||||||
|
if data, err := json.Marshal(msg); err == nil {
|
||||||
|
app.Broker.broadcast <- data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: hard delete from DB
|
||||||
|
_, err := app.DB.Exec("DELETE FROM orders WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) HandleGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := app.DB.Query("SELECT id, username, role FROM users WHERE username != 'superadmin' ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := make([]map[string]any, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id, role int
|
||||||
|
var username string
|
||||||
|
rows.Scan(&id, &username, &role)
|
||||||
|
users = append(users, map[string]any{"id": id, "username": username, "role": role})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role *int `json:"role"` // pointer = optional
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" && req.Password == "" && req.Role == nil {
|
||||||
|
http.Error(w, "nothing to update", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// build dynamic UPDATE
|
||||||
|
query := "UPDATE users SET "
|
||||||
|
args := []any{}
|
||||||
|
first := true
|
||||||
|
if req.Username != "" {
|
||||||
|
if !first {
|
||||||
|
query += ", "
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
query += "username = ?"
|
||||||
|
args = append(args, req.Username)
|
||||||
|
}
|
||||||
|
if req.Password != "" {
|
||||||
|
if !first {
|
||||||
|
query += ", "
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
query += "password = ?"
|
||||||
|
args = append(args, req.Password)
|
||||||
|
}
|
||||||
|
if req.Role != nil {
|
||||||
|
if !first {
|
||||||
|
query += ", "
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
query += "role = ?"
|
||||||
|
args = append(args, *req.Role)
|
||||||
|
}
|
||||||
|
query += " WHERE id = ?"
|
||||||
|
args = append(args, id)
|
||||||
|
|
||||||
|
res, err := app.DB.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
http.Error(w, "user not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
res, err := app.DB.Exec("DELETE FROM users WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
http.Error(w, "user not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) HandleGetAllOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := app.DB.Query("SELECT id, username, soup, main, side, created_at, status FROM orders ORDER BY id DESC")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Order
|
||||||
|
for rows.Next() {
|
||||||
|
var o Order
|
||||||
|
if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side, &o.CreatedAt, &o.Status); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
}
|
||||||
68
backend/handlers/api_moderator.go
Normal file
68
backend/handlers/api_moderator.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) HandleGetMenuRaw(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := app.DB.Query("SELECT id, category, name FROM menu_items ORDER BY id ASC")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []map[string]any
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var category, name string
|
||||||
|
if err := rows.Scan(&id, &category, &name); err == nil {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"category": category,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST new finalize time
|
||||||
|
func (app *App) HandleSetFinalizeTime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
http.Error(w, "bad request", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = app.DB.Exec(`INSERT OR REPLACE INTO settings (key,value) VALUES ('finalize_time', ?)`, in.Time)
|
||||||
|
|
||||||
|
// notify scheduler
|
||||||
|
select {
|
||||||
|
case app.FinalizeUpdate <- struct{}{}:
|
||||||
|
default: // don’t block if already queued
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST trigger finalize now
|
||||||
|
func (app *App) HandleFinalizeNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := finalizeOrders(app); err != nil {
|
||||||
|
http.Error(w, "failed", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "done"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) HandleGetLastSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if app.LastSummary == nil {
|
||||||
|
writeJSON(w, map[string]string{"status": "none"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, app.LastSummary)
|
||||||
|
}
|
||||||
150
backend/handlers/app.go
Normal file
150
backend/handlers/app.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FinalizedSummary struct {
|
||||||
|
Pickup []string `json:"pickup"`
|
||||||
|
Kitchen []string `json:"kitchen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Broker *Broker
|
||||||
|
FinalizeUpdate chan struct{} // notify scheduler to reload time
|
||||||
|
LastSummary *FinalizedSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(db *sql.DB, broker *Broker) *App {
|
||||||
|
return &App{
|
||||||
|
DB: db,
|
||||||
|
Broker: broker,
|
||||||
|
FinalizeUpdate: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) RequireLevel(maxAllowed int) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, err := usernameFromJWT(r)
|
||||||
|
if err != nil || username == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := app.getUserRole(username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if role > maxAllowed {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) getUserRole(username string) (int, error) {
|
||||||
|
var role int
|
||||||
|
err := app.DB.QueryRow("SELECT role FROM users WHERE username = ?", username).Scan(&role)
|
||||||
|
return role, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeOrders(app *App) error {
|
||||||
|
rows, err := app.DB.Query("SELECT id, username, soup, main, side FROM orders WHERE status = 'active'")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var summary []Order
|
||||||
|
for rows.Next() {
|
||||||
|
var o Order
|
||||||
|
if err := rows.Scan(&o.ID, &o.Username, &o.Soup, &o.Main, &o.Side); err == nil {
|
||||||
|
summary = append(summary, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: send summary (log, email, or message)
|
||||||
|
sendSummary(app, summary)
|
||||||
|
|
||||||
|
// Step 3: archive
|
||||||
|
_, err = app.DB.Exec("UPDATE orders SET status = 'history' WHERE status = 'active'")
|
||||||
|
|
||||||
|
// Step 4: broadcast deletions to SSE clients
|
||||||
|
for _, o := range summary {
|
||||||
|
msg := map[string]any{"id": o.ID, "status": "history", "event": "deleted"}
|
||||||
|
if data, err := json.Marshal(msg); err == nil {
|
||||||
|
app.Broker.broadcast <- data
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSummary(app *App, orders []Order) *FinalizedSummary {
|
||||||
|
// Pickup view
|
||||||
|
userMap := make(map[string][]string)
|
||||||
|
for _, o := range orders {
|
||||||
|
items := []string{}
|
||||||
|
if o.Soup != "" {
|
||||||
|
items = append(items, o.Soup)
|
||||||
|
}
|
||||||
|
items = append(items, o.Main, o.Side)
|
||||||
|
userMap[o.Username] = append(userMap[o.Username], items...)
|
||||||
|
}
|
||||||
|
pickup := []string{}
|
||||||
|
for user, items := range userMap {
|
||||||
|
pickup = append(pickup, fmt.Sprintf("%s: [%s]", user, strings.Join(items, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kitchen view
|
||||||
|
kitchenMap := make(map[string]int)
|
||||||
|
for _, o := range orders {
|
||||||
|
if o.Soup != "" {
|
||||||
|
kitchenMap[o.Soup]++
|
||||||
|
}
|
||||||
|
kitchenMap[o.Main]++
|
||||||
|
kitchenMap[o.Side]++
|
||||||
|
}
|
||||||
|
kitchen := []string{}
|
||||||
|
for item, count := range kitchenMap {
|
||||||
|
kitchen = append(kitchen, fmt.Sprintf("%s: %d", item, count))
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := &FinalizedSummary{Pickup: pickup, Kitchen: kitchen}
|
||||||
|
app.LastSummary = summary
|
||||||
|
|
||||||
|
// Log
|
||||||
|
log.Println("=== Pickup view ===")
|
||||||
|
for _, line := range pickup {
|
||||||
|
log.Println(line)
|
||||||
|
}
|
||||||
|
log.Println("=== Kitchen view ===")
|
||||||
|
for _, line := range kitchen {
|
||||||
|
log.Println(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) getSetting(key, def string) string {
|
||||||
|
var v string
|
||||||
|
err := app.DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&v)
|
||||||
|
if err != nil || v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
45
backend/handlers/broker.go
Normal file
45
backend/handlers/broker.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
type Broker struct {
|
||||||
|
clients map[chan []byte]bool
|
||||||
|
add chan chan []byte
|
||||||
|
remove chan chan []byte
|
||||||
|
broadcast chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBroker() *Broker {
|
||||||
|
b := &Broker{
|
||||||
|
clients: make(map[chan []byte]bool),
|
||||||
|
add: make(chan chan []byte),
|
||||||
|
remove: make(chan chan []byte),
|
||||||
|
broadcast: make(chan []byte),
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case c := <-b.add:
|
||||||
|
b.clients[c] = true
|
||||||
|
log.Printf("SSE client added. Total: %d", len(b.clients))
|
||||||
|
case c := <-b.remove:
|
||||||
|
if _, ok := b.clients[c]; ok {
|
||||||
|
delete(b.clients, c)
|
||||||
|
close(c)
|
||||||
|
log.Printf("SSE client removed. Total: %d", len(b.clients))
|
||||||
|
}
|
||||||
|
case msg := <-b.broadcast:
|
||||||
|
log.Printf("Broker broadcasting to %d clients", len(b.clients))
|
||||||
|
for c := range b.clients {
|
||||||
|
select {
|
||||||
|
case c <- msg:
|
||||||
|
log.Println("Message queued for client")
|
||||||
|
default:
|
||||||
|
log.Println("Client channel full, skipping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return b
|
||||||
|
}
|
||||||
46
backend/handlers/daily_cleanup.go
Normal file
46
backend/handlers/daily_cleanup.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartDailyCleanup(app *App) {
|
||||||
|
loc, _ := time.LoadLocation("Europe/Budapest")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
// read finalize_time from DB (default "10:30")
|
||||||
|
t := app.getSetting("finalize_time", "10:30")
|
||||||
|
|
||||||
|
// parse "HH:MM"
|
||||||
|
parts := strings.Split(t, ":")
|
||||||
|
hour, _ := strconv.Atoi(parts[0])
|
||||||
|
minute, _ := strconv.Atoi(parts[1])
|
||||||
|
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
|
||||||
|
if !next.After(now) {
|
||||||
|
next = next.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Next finalize scheduled", "time", next)
|
||||||
|
|
||||||
|
timer := time.NewTimer(time.Until(next))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
if err := finalizeOrders(app); err != nil {
|
||||||
|
slog.Error("Daily finalize failed", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("Orders finalized", "time", time.Now().In(loc))
|
||||||
|
}
|
||||||
|
case <-app.FinalizeUpdate:
|
||||||
|
slog.Info("Finalize time updated, recalculating...")
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
13
backend/handlers/json_helpers.go
Normal file
13
backend/handlers/json_helpers.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/handlers/jwt.go
Normal file
41
backend/handlers/jwt.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jwtSecret = []byte("supersecretkey") // TODO: move to env variable
|
||||||
|
|
||||||
|
func generateToken(userID int, username string) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": userID,
|
||||||
|
"username": username,
|
||||||
|
"exp": time.Now().Add(2 * time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(jwtSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usernameFromJWT(r *http.Request) (string, error) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
tokenStr := parts[1]
|
||||||
|
|
||||||
|
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { return jwtSecret, nil })
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
||||||
|
if u, ok := claims["username"].(string); ok {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
45
backend/handlers/types.go
Normal file
45
backend/handlers/types.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
type (
|
||||||
|
Foetel = string
|
||||||
|
Leves = string
|
||||||
|
Koret = string
|
||||||
|
)
|
||||||
|
|
||||||
|
type Valasztek struct {
|
||||||
|
Foetelek []Foetel `json:"foetelek"`
|
||||||
|
Levesek []Leves `json:"levesek"`
|
||||||
|
Koretek []Koret `json:"koretek"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Menu struct {
|
||||||
|
Foetelek []Foetel
|
||||||
|
Levesek []Leves
|
||||||
|
Koretek []Koret
|
||||||
|
}
|
||||||
|
|
||||||
|
type Order struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Soup string `json:"soup"`
|
||||||
|
Main string `json:"main"`
|
||||||
|
Side string `json:"side"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectionRequest struct {
|
||||||
|
Main string `json:"main"`
|
||||||
|
Side string `json:"side"`
|
||||||
|
Soup string `json:"soup"`
|
||||||
|
}
|
||||||
1072
backend/main.go
1072
backend/main.go
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role INTEGER NOT NULL DEFAULT 2
|
||||||
|
)
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO settings (key, value) VALUES ('finalize_time', '10:30');
|
||||||
|
INSERT INTO settings (key, value) VALUES ('seeded', 'false');
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS settings;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS menu_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
)
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS menu_items;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS selections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
main TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
soup TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS selections;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
soup TEXT,
|
||||||
|
main TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS orders;
|
||||||
|
-- +goose StatementEnd
|
||||||
82
backend/server.go
Normal file
82
backend/server.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"menu/handlers"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewServer(app *handlers.App, address string, allowedOrigins string) *http.Server {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: makeAllowedOrigins(allowedOrigins),
|
||||||
|
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 300,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Routes (bind methods to app)
|
||||||
|
r.Post("/api/register", app.HandleRegister)
|
||||||
|
r.Post("/api/login", app.HandleLogin)
|
||||||
|
r.Get("/api/options", app.HandleOptions)
|
||||||
|
r.Post("/api/selection", app.HandleSaveSelection)
|
||||||
|
r.Get("/api/selection", app.HandleGetSelection)
|
||||||
|
r.Get("/api/orders", app.HandleGetOrders)
|
||||||
|
r.Post("/api/orders", app.HandleAddOrder)
|
||||||
|
r.Get("/api/orders/stream", app.HandleOrdersStream)
|
||||||
|
r.Delete("/api/orders/{id}", app.HandleDeleteOrder)
|
||||||
|
r.Get("/api/me", app.HandleWhoAmI)
|
||||||
|
r.Get("/api/finalize/time", app.HandleGetFinalizeTime)
|
||||||
|
|
||||||
|
// Only role 0 and 1 allowed
|
||||||
|
r.Route("/api/admin", func(r chi.Router) {
|
||||||
|
r.Use(app.RequireLevel(1))
|
||||||
|
r.Post("/menu", app.HandleAdminMenu)
|
||||||
|
r.Get("/menu", app.HandleGetMenuRaw)
|
||||||
|
r.Get("/users", app.HandleGetUsers)
|
||||||
|
r.Delete("/users/{id}", app.HandleDeleteUser)
|
||||||
|
r.Put("/users/{id}", app.HandleUpdateUser)
|
||||||
|
r.Get("/orders", app.HandleGetAllOrders)
|
||||||
|
r.Put("/orders/{id}/status", app.HandleAdminUpdateOrderStatus)
|
||||||
|
r.Delete("/orders/{id}", app.HandleAdminDeleteOrder)
|
||||||
|
r.Get("/finalize/time", app.HandleGetFinalizeTime)
|
||||||
|
r.Post("/finalize/time", app.HandleSetFinalizeTime)
|
||||||
|
r.Post("/finalize/now", app.HandleFinalizeNow)
|
||||||
|
r.Get("/finalize/last", app.HandleGetLastSummary)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Moderators (role <= 50)
|
||||||
|
r.Route("/api/mod", func(r chi.Router) {
|
||||||
|
r.Use(app.RequireLevel(50))
|
||||||
|
r.Post("/menu", app.HandleAdminMenu) // menu editor
|
||||||
|
r.Get("/menu", app.HandleGetMenuRaw)
|
||||||
|
r.Get("/finalize/time", app.HandleGetFinalizeTime)
|
||||||
|
r.Post("/finalize/time", app.HandleSetFinalizeTime)
|
||||||
|
r.Post("/finalize/now", app.HandleFinalizeNow)
|
||||||
|
r.Get("/finalize/last", app.HandleGetLastSummary)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return configured server
|
||||||
|
return &http.Server{
|
||||||
|
Addr: address,
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAllowedOrigins(origins string) []string {
|
||||||
|
if origins == "" {
|
||||||
|
origins = "*"
|
||||||
|
}
|
||||||
|
return strings.Split(strings.TrimSpace(origins), ",")
|
||||||
|
}
|
||||||
28
backend/setup_data_dir.go
Normal file
28
backend/setup_data_dir.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupDataDir() (string, error) {
|
||||||
|
var dataDir string
|
||||||
|
if os.Getenv("DATA_DIR") == "" {
|
||||||
|
dataDir = filepath.Join(".", "data")
|
||||||
|
} else {
|
||||||
|
dataDir = filepath.Join(".", os.Getenv("DATA_DIR"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsLocal(dataDir) {
|
||||||
|
return "", fmt.Errorf("directory '%s' is not valid or not local", dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dataDir); os.IsNotExist(err) {
|
||||||
|
err := os.Mkdir(dataDir, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot create '%s' directory: %w", dataDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dataDir, nil
|
||||||
|
}
|
||||||
38
backend/setup_default_logger.go
Normal file
38
backend/setup_default_logger.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupDefaultLogger() {
|
||||||
|
if strings.ToLower(os.Getenv("PROD")) == "true" {
|
||||||
|
options := &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
}
|
||||||
|
customLogger := slog.New(slog.NewTextHandler(os.Stdout, options))
|
||||||
|
slog.SetDefault(customLogger)
|
||||||
|
slog.Info("Production logger initialized", slog.Group("app", "version", Version, "build_time", BuildTime))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceFileName := func(groups []string, a slog.Attr) slog.Attr {
|
||||||
|
// Remove the directory and function from the source's filename.
|
||||||
|
if a.Key == slog.SourceKey {
|
||||||
|
source := a.Value.Any().(*slog.Source)
|
||||||
|
source.File = filepath.Base(source.File)
|
||||||
|
source.Function = ""
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
options := &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
AddSource: true,
|
||||||
|
ReplaceAttr: sourceFileName,
|
||||||
|
}
|
||||||
|
customLogger := slog.New(slog.NewTextHandler(os.Stdout, options))
|
||||||
|
slog.SetDefault(customLogger)
|
||||||
|
slog.Info("Development logger initialized", slog.Group("app", "version", Version, "build_time", BuildTime))
|
||||||
|
}
|
||||||
50
feedmee/Dockerfile
Normal file
50
feedmee/Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:25-alpine AS base
|
||||||
|
|
||||||
|
# 1. Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||||
|
RUN \
|
||||||
|
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
|
elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# 2. Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 3. Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs
|
||||||
|
RUN adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
CMD HOSTNAME="0.0.0.0" node server.js
|
||||||
@@ -2,7 +2,13 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
compress: false,
|
compress: false,
|
||||||
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev', '172.17.96.1', '192.168.1.135', '0.0.0.0'],
|
allowedDevOrigins: [
|
||||||
|
"local-origin.dev",
|
||||||
|
"*.local-origin.dev",
|
||||||
|
"172.17.96.1",
|
||||||
|
"192.168.1.135",
|
||||||
|
"0.0.0.0",
|
||||||
|
],
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -11,6 +17,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { TopBar } from "@/components/ui/topbar";
|
import { TopBar } from "@/components/ui/topbar";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const API_URL = "";
|
const API_URL = "";
|
||||||
|
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -31,11 +28,10 @@ type Order = {
|
|||||||
|
|
||||||
type FinalizedSummary = { pickup: string[]; kitchen: string[] };
|
type FinalizedSummary = { pickup: string[]; kitchen: string[] };
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
type MenuItem = {
|
id: number;
|
||||||
id: number;
|
category: string;
|
||||||
category: string;
|
name: string;
|
||||||
name: string
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
@@ -55,40 +51,45 @@ export default function AdminPage() {
|
|||||||
const [lastSummary, setLastSummary] = useState<FinalizedSummary | null>(null);
|
const [lastSummary, setLastSummary] = useState<FinalizedSummary | null>(null);
|
||||||
|
|
||||||
const auth = (): Record<string, string> => {
|
const auth = (): Record<string, string> => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/admin/finalize/time`, { headers: auth() })
|
fetch(`${API_URL}/api/admin/finalize/time`, { headers: auth() })
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => setFinalizeTime(data.time));
|
.then((data) => setFinalizeTime(data.time));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push("/auth");
|
router.push("/auth");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(`${API_URL}/api/me`, {
|
fetch(`${API_URL}/api/me`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
.then(res => res.json())
|
.then((res) => {
|
||||||
.then(data => {
|
console.log(res);
|
||||||
if (!data.role || data.role > 1) {
|
return res.json();
|
||||||
router.push("/landing");
|
})
|
||||||
|
.then((data: { role?: number; username?: string }) => {
|
||||||
|
if (data.role == undefined || data.role > 1) {
|
||||||
|
console.log(data);
|
||||||
|
console.log("Not admin, redirecting");
|
||||||
|
router.push("/landing");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => router.push("/auth"));
|
.catch(() => router.push("/auth"));
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/admin/users`, { headers: auth() })
|
fetch(`${API_URL}/api/admin/users`, { headers: auth() })
|
||||||
.then(r => r.ok ? r.json() : [])
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
.then(setUsers)
|
.then(setUsers)
|
||||||
.catch(() => setUsers([]));
|
.catch(() => setUsers([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,9 +98,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/admin/menu`, { headers: auth() })
|
fetch(`${API_URL}/api/admin/menu`, { headers: auth() })
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data: MenuItem[]) => setMenuItems(data || []))
|
.then((data: MenuItem[]) => setMenuItems(data || []))
|
||||||
.catch(() => setMenuItems([]));
|
.catch(() => setMenuItems([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,15 +109,17 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
async function refreshOrders() {
|
async function refreshOrders() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/admin/orders`, { headers: auth() });
|
const res = await fetch(`${API_URL}/api/admin/orders`, {
|
||||||
if (res.ok) {
|
headers: auth(),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
const data: Order[] = await res.json();
|
const data: Order[] = await res.json();
|
||||||
setOrders(data || []);
|
setOrders(data || []);
|
||||||
} else {
|
} else {
|
||||||
setOrders([]);
|
setOrders([]);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setOrders([]);
|
setOrders([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +131,13 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
async function addMenuItem() {
|
async function addMenuItem() {
|
||||||
await fetch(`${API_URL}/api/admin/menu`, {
|
await fetch(`${API_URL}/api/admin/menu`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...auth() },
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
body: JSON.stringify({ action: "add", category: newCategory, name: newItem }),
|
body: JSON.stringify({
|
||||||
|
action: "add",
|
||||||
|
category: newCategory,
|
||||||
|
name: newItem,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
setNewItem("");
|
setNewItem("");
|
||||||
await refreshMenu();
|
await refreshMenu();
|
||||||
@@ -139,9 +146,13 @@ export default function AdminPage() {
|
|||||||
async function updateMenuItem() {
|
async function updateMenuItem() {
|
||||||
if (!editingItem) return;
|
if (!editingItem) return;
|
||||||
await fetch(`${API_URL}/api/admin/menu`, {
|
await fetch(`${API_URL}/api/admin/menu`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...auth() },
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
body: JSON.stringify({ action: "update", id: editingItem.id, name: editingItem.name }),
|
body: JSON.stringify({
|
||||||
|
action: "update",
|
||||||
|
id: editingItem.id,
|
||||||
|
name: editingItem.name,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
await refreshMenu();
|
await refreshMenu();
|
||||||
@@ -149,47 +160,55 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
async function deleteMenuItem(id: number) {
|
async function deleteMenuItem(id: number) {
|
||||||
await fetch(`${API_URL}/api/admin/menu`, {
|
await fetch(`${API_URL}/api/admin/menu`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...auth() },
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
body: JSON.stringify({ action: "delete", id }),
|
body: JSON.stringify({ action: "delete", id }),
|
||||||
});
|
});
|
||||||
await refreshMenu();
|
await refreshMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(id: number) {
|
async function deleteUser(id: number) {
|
||||||
if (!confirm("Biztos törlöd a felhasználót?")) return;
|
if (!confirm("Biztos törlöd a felhasználót?")) return;
|
||||||
await fetch(`${API_URL}/api/admin/users/${id}`, { method: "DELETE", headers: auth() });
|
await fetch(`${API_URL}/api/admin/users/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: auth(),
|
||||||
|
});
|
||||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUser(id: number) {
|
async function updateUser(id: number) {
|
||||||
const body: any = {};
|
const body: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
role?: number;
|
||||||
|
} = {};
|
||||||
|
|
||||||
if (newUsername) body.username = newUsername;
|
if (newUsername) body.username = newUsername;
|
||||||
if (newPassword) body.password = newPassword;
|
if (newPassword) body.password = newPassword;
|
||||||
if (newRole !== null) body.role = newRole;
|
if (newRole !== null) body.role = newRole;
|
||||||
|
|
||||||
await fetch(`${API_URL}/api/admin/users/${id}`, {
|
await fetch(`${API_URL}/api/admin/users/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json", ...auth() },
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) =>
|
prev.map((u) =>
|
||||||
u.id === id ? { ...u, username: body.username || u.username } : u
|
u.id === id ? { ...u, username: body.username || u.username } : u
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) =>
|
prev.map((u) =>
|
||||||
u.id === id
|
u.id === id
|
||||||
? {
|
? {
|
||||||
...u,
|
...u,
|
||||||
username: body.username ?? u.username,
|
username: body.username ?? u.username,
|
||||||
role: body.role ?? u.role,
|
role: body.role ?? u.role,
|
||||||
}
|
}
|
||||||
: u
|
: u
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -200,17 +219,17 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
function toggleSelect(id: number) {
|
function toggleSelect(id: number) {
|
||||||
setSelectedOrders((prev) =>
|
setSelectedOrders((prev) =>
|
||||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateStatusSelected(status: string) {
|
async function updateStatusSelected(status: string) {
|
||||||
for (const id of selectedOrders) {
|
for (const id of selectedOrders) {
|
||||||
await fetch(`${API_URL}/api/admin/orders/${id}/status`, {
|
await fetch(`${API_URL}/api/admin/orders/${id}/status`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json", ...auth() },
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedOrders([]);
|
setSelectedOrders([]);
|
||||||
@@ -219,14 +238,17 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
selectedOrders.map(async (id) => {
|
selectedOrders.map(async (id) => {
|
||||||
await fetch(`${API_URL}/api/admin/orders/${id}/status`, {
|
await fetch(`${API_URL}/api/admin/orders/${id}/status`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ status: "history" }),
|
body: JSON.stringify({ status: "history" }),
|
||||||
});
|
});
|
||||||
await fetch(`${API_URL}/api/admin/orders/${id}`, { method: "DELETE", headers: auth() });
|
await fetch(`${API_URL}/api/admin/orders/${id}`, {
|
||||||
})
|
method: "DELETE",
|
||||||
|
headers: auth(),
|
||||||
|
});
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const res = await fetch(`${API_URL}/api/admin/orders`);
|
const res = await fetch(`${API_URL}/api/admin/orders`);
|
||||||
setSelectedOrders([]);
|
setSelectedOrders([]);
|
||||||
@@ -235,14 +257,16 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
async function saveFinalizeTime() {
|
async function saveFinalizeTime() {
|
||||||
await fetch(`${API_URL}/api/admin/finalize/time`, {
|
await fetch(`${API_URL}/api/admin/finalize/time`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...auth() },
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
body: JSON.stringify({ time: finalizeTime }),
|
body: JSON.stringify({ time: finalizeTime }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLastSummary() {
|
async function loadLastSummary() {
|
||||||
const res = await fetch(`${API_URL}/api/mod/finalize/last`, { headers: auth() });
|
const res = await fetch(`${API_URL}/api/mod/finalize/last`, {
|
||||||
|
headers: auth(),
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.status !== "none") setLastSummary(data);
|
if (data.status !== "none") setLastSummary(data);
|
||||||
else setLastSummary(null);
|
else setLastSummary(null);
|
||||||
@@ -250,341 +274,360 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
async function triggerFinalizeNow() {
|
async function triggerFinalizeNow() {
|
||||||
await fetch(`${API_URL}/api/admin/finalize/now`, {
|
await fetch(`${API_URL}/api/admin/finalize/now`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: auth(),
|
headers: auth(),
|
||||||
});
|
});
|
||||||
await loadLastSummary();
|
await loadLastSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative flex flex-col min-h-screen items-center p-4 pb-16 space-y-6">
|
<main className="relative flex flex-col min-h-screen items-center p-4 pb-16 space-y-6">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<div className="h-10" />
|
<div className="h-10" />
|
||||||
|
|
||||||
<h1 className="text-white text-3xl sm:text-4xl font-bold text-center">
|
<h1 className="text-white text-3xl sm:text-4xl font-bold text-center">
|
||||||
Users
|
Users
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="w-full max-w-3xl">
|
<div className="w-full max-w-3xl">
|
||||||
<div className="glass-panel max-h-[400px] overflow-y-auto space-y-4 p-4">
|
<div className="glass-panel max-h-[400px] overflow-y-auto space-y-4 p-4">
|
||||||
{users.length > 0 ? (
|
{users.length > 0 ? (
|
||||||
users.map((u) => (
|
users.map((u) => (
|
||||||
<Card
|
<Card
|
||||||
key={u.id}
|
key={u.id}
|
||||||
className="glass-panel flex flex-col sm:flex-row sm:items-center sm:justify-between p-4 gap-3"
|
className="glass-panel flex flex-col sm:flex-row sm:items-center sm:justify-between p-4 gap-3"
|
||||||
>
|
>
|
||||||
{editing === u.id ? (
|
{editing === u.id ? (
|
||||||
<div className="flex flex-col sm:flex-row gap-2 w-full items-center justify-between">
|
<div className="flex flex-col sm:flex-row gap-2 w-full items-center justify-between">
|
||||||
{/* Keep current username visible */}
|
{/* Keep current username visible */}
|
||||||
<span className="text-white font-medium">
|
<span className="text-white font-medium">
|
||||||
{u.username} <span className="text-sm text-white/60">(role: {u.role})</span>
|
{u.username}{" "}
|
||||||
</span>
|
<span className="text-sm text-white/60">
|
||||||
|
(role: {u.role})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Inputs + buttons */}
|
{/* Inputs + buttons */}
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="New Username"
|
placeholder="New Username"
|
||||||
value={newUsername}
|
value={newUsername}
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
className="bg-white/70 text-black sm:w-40 border-white"
|
className="bg-white/70 text-black sm:w-40 border-white"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="New Password"
|
placeholder="New Password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
className="bg-white/70 text-black sm:w-40 border-white"
|
className="bg-white/70 text-black sm:w-40 border-white"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Role"
|
placeholder="Role"
|
||||||
value={newRole !== null ? newRole : ""}
|
value={newRole !== null ? newRole : ""}
|
||||||
onChange={(e) => setNewRole(Number(e.target.value))}
|
onChange={(e) => setNewRole(Number(e.target.value))}
|
||||||
className="bg-white/70 text-black sm:w-20 border-white"
|
className="bg-white/70 text-black sm:w-20 border-white"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateUser(u.id)}
|
onClick={() => updateUser(u.id)}
|
||||||
className="bg-black/35 hover:bg-green-700/35 px-3"
|
className="bg-black/35 hover:bg-green-700/35 px-3"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEditing(null)}
|
onClick={() => setEditing(null)}
|
||||||
className="bg-black/35 hover:bg-blue-700/35 px-3"
|
className="bg-black/35 hover:bg-blue-700/35 px-3"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : (
|
||||||
<span className="text-white font-medium">
|
<>
|
||||||
{u.username} <span className="text-sm text-white/60">(role: {u.role})</span>
|
<span className="text-white font-medium">
|
||||||
</span>
|
{u.username}{" "}
|
||||||
<div className="flex gap-2">
|
<span className="text-sm text-white/60">
|
||||||
<Button
|
(role: {u.role})
|
||||||
size="sm"
|
</span>
|
||||||
onClick={() => setEditing(u.id)}
|
</span>
|
||||||
className="bg-black/35 hover:bg-blue-700/35 px-3"
|
<div className="flex gap-2">
|
||||||
>
|
<Button
|
||||||
Edit
|
size="sm"
|
||||||
</Button>
|
onClick={() => setEditing(u.id)}
|
||||||
<Button
|
className="bg-black/35 hover:bg-blue-700/35 px-3"
|
||||||
size="sm"
|
>
|
||||||
onClick={() => deleteUser(u.id)}
|
Edit
|
||||||
className="bg-red-600 hover:bg-red-700 px-3"
|
</Button>
|
||||||
>
|
<Button
|
||||||
Delete
|
size="sm"
|
||||||
</Button>
|
onClick={() => deleteUser(u.id)}
|
||||||
</div>
|
className="bg-red-600 hover:bg-red-700 px-3"
|
||||||
</>
|
>
|
||||||
)}
|
Delete
|
||||||
|
</Button>
|
||||||
</Card>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-white/70 text-center">No registered users.</p>
|
<p className="text-white/70 text-center">No registered users.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Orders panel */}
|
{/* Orders panel */}
|
||||||
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
||||||
Orders
|
Orders
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="glass-panel w-full max-w-3xl">
|
<div className="glass-panel w-full max-w-3xl">
|
||||||
<div className="max-h-[400px] overflow-y-auto space-y-2 p-4">
|
<div className="max-h-[400px] overflow-y-auto space-y-2 p-4">
|
||||||
{orders.length > 0 ? (
|
{orders.length > 0 ? (
|
||||||
orders.map((o) => (
|
orders.map((o) => (
|
||||||
<Card
|
<Card
|
||||||
key={o.id}
|
key={o.id}
|
||||||
onClick={() => toggleSelect(o.id)}
|
onClick={() => toggleSelect(o.id)}
|
||||||
className={`glass-panel p-3 cursor-pointer ${
|
className={`glass-panel p-3 cursor-pointer ${
|
||||||
selectedOrders.includes(o.id) ? "ring-2 ring-orange-400" : ""
|
selectedOrders.includes(o.id) ? "ring-2 ring-orange-400" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CardContent className="text-white flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
|
<CardContent className="text-white flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<strong>{o.username}:</strong>{" "}
|
<strong>{o.username}:</strong>{" "}
|
||||||
{o.soup && o.soup.trim() !== "" ? `${o.soup}, ` : ""}
|
{o.soup && o.soup.trim() !== "" ? `${o.soup}, ` : ""}
|
||||||
{o.main}, {o.side}
|
{o.main}, {o.side}
|
||||||
<span
|
<span
|
||||||
className={`ml-2 px-2 py-0.5 rounded text-xs ${
|
className={`ml-2 px-2 py-0.5 rounded text-xs ${
|
||||||
o.status === "active"
|
o.status === "active"
|
||||||
? "bg-green-600/70"
|
? "bg-green-600/70"
|
||||||
: o.status === "history"
|
: o.status === "history"
|
||||||
? "bg-gray-600/70"
|
? "bg-gray-600/70"
|
||||||
: "bg-red-600/70"
|
: "bg-red-600/70"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{o.status}
|
{o.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-white/60">
|
<span className="text-sm text-white/60">
|
||||||
{new Date(o.created_at).toLocaleString()}
|
{new Date(o.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-white/70 text-center">No orders to display.</p>
|
<p className="text-white/70 text-center">No orders to display.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex gap-3 justify-center mt-4 pb-4">
|
<div className="flex gap-3 justify-center mt-4 pb-4">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={refreshOrders}
|
onClick={refreshOrders}
|
||||||
className="text-white hover:bg-blue-700/40 rounded-full p-2"
|
className="text-white hover:bg-blue-700/40 rounded-full p-2"
|
||||||
title="Frissítés"
|
title="Frissítés"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-green-600 hover:bg-green-700 px-4"
|
className="bg-green-600 hover:bg-green-700 px-4"
|
||||||
onClick={() => updateStatusSelected("active")}
|
onClick={() => updateStatusSelected("active")}
|
||||||
>
|
>
|
||||||
Set Active
|
Set Active
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-gray-600 hover:bg-gray-700 px-4"
|
className="bg-gray-600 hover:bg-gray-700 px-4"
|
||||||
onClick={() => updateStatusSelected("history")}
|
onClick={() => updateStatusSelected("history")}
|
||||||
>
|
>
|
||||||
Set History
|
Set History
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-red-600 hover:bg-red-700 px-4"
|
className="bg-red-600 hover:bg-red-700 px-4"
|
||||||
onClick={deleteSelected}
|
onClick={deleteSelected}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu Management Panel */}
|
|
||||||
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
|
||||||
Menu Editor
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="glass-panel w-full max-w-6xl p-4">
|
{/* Menu Management Panel */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
||||||
{["soup", "main", "side"].map((cat) => (
|
Menu Editor
|
||||||
<div key={cat} className="flex flex-col">
|
</h2>
|
||||||
<h3 className="text-center text-xl font-semibold text-white mb-2">
|
|
||||||
{cat === "soup" ? "Soups" : cat === "main" ? "Main courses" : "Sides"}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="max-h-[300px] overflow-y-auto space-y-2">
|
<div className="glass-panel w-full max-w-6xl p-4">
|
||||||
{menuItems.filter((i) => i.category === cat).map((item) => (
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<Card
|
{["soup", "main", "side"].map((cat) => (
|
||||||
key={item.id}
|
<div key={cat} className="flex flex-col">
|
||||||
className="glass-panel flex flex-col lg:flex-row md:items-center sm:justify-between p-2 gap-2"
|
<h3 className="text-center text-xl font-semibold text-white mb-2">
|
||||||
>
|
{cat === "soup"
|
||||||
{editingItem?.id === item.id ? (
|
? "Soups"
|
||||||
<div className="flex flex-col lg:flex-row gap-2 w-full items-center justify-between">
|
: cat === "main"
|
||||||
<Input
|
? "Main courses"
|
||||||
value={editingItem.name}
|
: "Sides"}
|
||||||
onChange={(e) =>
|
</h3>
|
||||||
setEditingItem({ ...editingItem, name: e.target.value })
|
|
||||||
}
|
|
||||||
className="bg-white/70 text-black sm:w-40 border-white"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={updateMenuItem}
|
|
||||||
className="bg-green-600 hover:bg-green-700 px-3"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditingItem(null)}
|
|
||||||
className="bg-gray-600 hover:bg-gray-700 px-3"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="text-white font-medium">{item.name}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditingItem(item)}
|
|
||||||
className="bg-black/35 hover:bg-blue-700/35 px-3"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteMenuItem(item.id)}
|
|
||||||
className="bg-red-600 hover:bg-red-700 px-3"
|
|
||||||
>
|
|
||||||
Del
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add new item for this category */}
|
<div className="max-h-[300px] overflow-y-auto space-y-2">
|
||||||
<div className="flex flex-col sm:flex-row gap-2 mt-3">
|
{menuItems
|
||||||
<Input
|
.filter((i) => i.category === cat)
|
||||||
placeholder={`Új ${cat === "soup" ? "Soup" : cat === "main" ? "Main" : "Side"}`}
|
.map((item) => (
|
||||||
value={newCategory === cat ? newItem : ""}
|
<Card
|
||||||
onChange={(e) => {
|
key={item.id}
|
||||||
setNewItem(e.target.value);
|
className="glass-panel flex flex-col lg:flex-row md:items-center sm:justify-between p-2 gap-2"
|
||||||
setNewCategory(cat);
|
|
||||||
}}
|
|
||||||
className="bg-white/70 text-black sm:w-2/3 border-white"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={addMenuItem}
|
|
||||||
className="bg-green-600 hover:bg-green-700 px-4"
|
|
||||||
>
|
>
|
||||||
Add
|
{editingItem?.id === item.id ? (
|
||||||
</Button>
|
<div className="flex flex-col lg:flex-row gap-2 w-full items-center justify-between">
|
||||||
</div>
|
<Input
|
||||||
</div>
|
value={editingItem.name}
|
||||||
))}
|
onChange={(e) =>
|
||||||
</div>
|
setEditingItem({
|
||||||
</div>
|
...editingItem,
|
||||||
|
name: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-white/70 text-black sm:w-40 border-white"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={updateMenuItem}
|
||||||
|
className="bg-green-600 hover:bg-green-700 px-3"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingItem(null)}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 px-3"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingItem(item)}
|
||||||
|
className="bg-black/35 hover:bg-blue-700/35 px-3"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteMenuItem(item.id)}
|
||||||
|
className="bg-red-600 hover:bg-red-700 px-3"
|
||||||
|
>
|
||||||
|
Del
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
{/* Add new item for this category */}
|
||||||
Order Finalization
|
<div className="flex flex-col sm:flex-row gap-2 mt-3">
|
||||||
</h2>
|
<Input
|
||||||
|
placeholder={`Új ${
|
||||||
<div className="glass-panel flex flex-col sm:items-center w-full max-w-3xl p-4 space-y-4">
|
cat === "soup" ? "Soup" : cat === "main" ? "Main" : "Side"
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
}`}
|
||||||
<input
|
value={newCategory === cat ? newItem : ""}
|
||||||
type="time"
|
onChange={(e) => {
|
||||||
value={finalizeTime}
|
setNewItem(e.target.value);
|
||||||
onChange={(e) => setFinalizeTime(e.target.value)}
|
setNewCategory(cat);
|
||||||
className="bg-white/70 text-black rounded px-2 py-1 border border-white w-full sm:w-40"
|
}}
|
||||||
|
className="bg-white/70 text-black sm:w-2/3 border-white"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={saveFinalizeTime}
|
size="sm"
|
||||||
className="bg-green-600 hover:bg-green-700 px-4 w-full sm:w-auto"
|
onClick={addMenuItem}
|
||||||
|
className="bg-green-600 hover:bg-green-700 px-4"
|
||||||
>
|
>
|
||||||
Save Time
|
Add
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={triggerFinalizeNow}
|
|
||||||
className="bg-red-600 hover:bg-red-700 px-4 w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Finalize Orders Now
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
||||||
|
Order Finalization
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="glass-panel flex flex-col sm:items-center w-full max-w-3xl p-4 space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={finalizeTime}
|
||||||
|
onChange={(e) => setFinalizeTime(e.target.value)}
|
||||||
|
className="bg-white/70 text-black rounded px-2 py-1 border border-white w-full sm:w-40"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={saveFinalizeTime}
|
||||||
|
className="bg-green-600 hover:bg-green-700 px-4 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Save Time
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={triggerFinalizeNow}
|
||||||
|
className="bg-red-600 hover:bg-red-700 px-4 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Finalize Orders Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Finalized Order Summary Panel */}
|
||||||
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
||||||
|
Legutóbbi összesítés
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="glass-panel w-full max-w-4xl p-4 space-y-4">
|
||||||
|
{lastSummary ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">
|
||||||
|
Átvételi nézet
|
||||||
|
</h3>
|
||||||
|
<ul className="list-disc list-inside text-white space-y-1">
|
||||||
|
{lastSummary.pickup.map((line, idx) => (
|
||||||
|
<li key={idx}>{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">
|
||||||
{/* Finalized Order Summary Panel */}
|
Konyha nézet
|
||||||
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
</h3>
|
||||||
Legutóbbi összesítés
|
<ul className="list-disc list-inside text-white space-y-1">
|
||||||
</h2>
|
{lastSummary.kitchen.map((line, idx) => (
|
||||||
|
<li key={idx}>{line}</li>
|
||||||
<div className="glass-panel w-full max-w-4xl p-4 space-y-4">
|
))}
|
||||||
{lastSummary ? (
|
</ul>
|
||||||
<>
|
</div>
|
||||||
<div>
|
</>
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Átvételi nézet</h3>
|
) : (
|
||||||
<ul className="list-disc list-inside text-white space-y-1">
|
<p className="text-white italic">Nincs elérhető összesítés.</p>
|
||||||
{lastSummary.pickup.map((line, idx) => (
|
)}
|
||||||
<li key={idx}>{line}</li>
|
</div>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Konyha nézet</h3>
|
|
||||||
<ul className="list-disc list-inside text-white space-y-1">
|
|
||||||
{lastSummary.kitchen.map((line, idx) => (
|
|
||||||
<li key={idx}>{line}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-white italic">Nincs elérhető összesítés.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export default function AuthPage() {
|
|||||||
localStorage.setItem("token", data.token);
|
localStorage.setItem("token", data.token);
|
||||||
localStorage.setItem("username", data.username);
|
localStorage.setItem("username", data.username);
|
||||||
router.push("/landing");
|
router.push("/landing");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message ?? "Failed to authenticate");
|
setError((err as Error).message ?? "Failed to authenticate");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,6 @@ export default function AuthPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative flex min-h-screen items-center justify-center p-6">
|
<main className="relative flex min-h-screen items-center justify-center p-6">
|
||||||
|
|
||||||
{/* Foreground content */}
|
{/* Foreground content */}
|
||||||
<div className="w-full max-w-sm space-y-8">
|
<div className="w-full max-w-sm space-y-8">
|
||||||
<h1 className="text-center text-5xl sm:text-6xl md:text-7xl font-bold text-white drop-shadow-lg">
|
<h1 className="text-center text-5xl sm:text-6xl md:text-7xl font-bold text-white drop-shadow-lg">
|
||||||
@@ -85,7 +84,11 @@ export default function AuthPage() {
|
|||||||
className="bg-white/60 text-black"
|
className="bg-white/60 text-black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="btn-brand w-full" disabled={loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="btn-brand w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
{loading
|
{loading
|
||||||
? "Please wait…"
|
? "Please wait…"
|
||||||
: mode === "login"
|
: mode === "login"
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -135,47 +132,43 @@
|
|||||||
w-full sm:w-1/2 lg:w-1/3 hover:bg-orange-600;
|
w-full sm:w-1/2 lg:w-1/3 hover:bg-orange-600;
|
||||||
}
|
}
|
||||||
.app-background {
|
.app-background {
|
||||||
position: fixed; /* stays put */
|
position: fixed; /* stays put */
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: -1; /* behind everything */
|
z-index: -1; /* behind everything */
|
||||||
background-image: url("/burger.jpg");
|
background-image: url("/burger.jpg");
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
pointer-events: none; /* make it untouchable */
|
pointer-events: none; /* make it untouchable */
|
||||||
touch-action: none; /* block drag gestures */
|
touch-action: none; /* block drag gestures */
|
||||||
}
|
}
|
||||||
.app-background::after {
|
.app-background::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.35); /* dark overlay */
|
background: rgba(0, 0, 0, 0.35); /* dark overlay */
|
||||||
}
|
}
|
||||||
.food-option {
|
.food-option {
|
||||||
@apply bg-white/5 text-white text-lg hover:bg-orange-500/60;
|
@apply bg-white/5 text-white text-lg hover:bg-orange-500/60;
|
||||||
}
|
}
|
||||||
.food-option-selected {
|
.food-option-selected {
|
||||||
@apply bg-orange-500/75 text-white shadow-xl text-lg;
|
@apply bg-orange-500/75 text-white shadow-xl text-lg;
|
||||||
box-shadow: 0 0 5px theme('colors.orange.400');
|
box-shadow: 0 0 5px theme("colors.orange.400");
|
||||||
text-shadow: 0 0 5px;
|
text-shadow: 0 0 5px;
|
||||||
}
|
}
|
||||||
/* Hide scrollbars but keep scroll functionality */
|
/* Hide scrollbars but keep scroll functionality */
|
||||||
.hide-scrollbar {
|
.hide-scrollbar {
|
||||||
-ms-overflow-style: none; /* IE/Edge */
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
.hide-scrollbar::-webkit-scrollbar {
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
display: none; /* Chrome/Safari */
|
display: none; /* Chrome/Safari */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Ensure html and body always fill viewport */
|
/* Ensure html and body always fill viewport */
|
||||||
body {
|
body {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TopBar } from "@/components/ui/topbar";
|
import { TopBar } from "@/components/ui/topbar";
|
||||||
|
|
||||||
|
|
||||||
const API_URL = "";
|
const API_URL = "";
|
||||||
|
|
||||||
type Order = {
|
type Order = {
|
||||||
@@ -137,17 +136,17 @@ export default function LandingPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* If no orders, show message */}
|
{/* If no orders, show message */}
|
||||||
{!orders.some((o) => o.username === username) && (
|
{!orders.some((o) => o.username === username) && (
|
||||||
<p className="text-white/70">Ma még nem rendeltél.</p>
|
<p className="text-white/70">Ma még nem rendeltél.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* New order button */}
|
{/* New order button */}
|
||||||
|
|
||||||
<text className="text-2xl text-white/80 text-center mt-2 ">
|
<p className="text-2xl text-white/80 text-center mt-2 ">
|
||||||
A rendeléseket minden nap {finalizeTime}-kor zárjuk le. Addig tudsz választani!
|
A rendeléseket minden nap {finalizeTime}-kor zárjuk le. Addig tudsz
|
||||||
</text>
|
választani!
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="food"
|
variant="food"
|
||||||
onClick={() => router.push("/menu")}
|
onClick={() => router.push("/menu")}
|
||||||
@@ -156,8 +155,6 @@ export default function LandingPage() {
|
|||||||
Új rendelés
|
Új rendelés
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Global orders */}
|
{/* Global orders */}
|
||||||
<hr className="w-full border-t border-white/30 my-6" />
|
<hr className="w-full border-t border-white/30 my-6" />
|
||||||
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-4">
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-4">
|
||||||
@@ -175,9 +172,7 @@ export default function LandingPage() {
|
|||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
<p className="text-center text-white/70">Nincs aktív rendelés</p>
|
||||||
<p className="text-center text-white/70">Nincs aktív rendelés</p>
|
|
||||||
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "FeedMe",
|
title: "FeedMe",
|
||||||
description: "Food ordering app",
|
description: "Food ordering app",
|
||||||
@@ -28,11 +16,9 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="relative text-white">
|
<body className={`relative text-white`}>
|
||||||
<div className="app-background"/>
|
<div className="app-background" />
|
||||||
<div className="relative z-10 h-screen overflow-y-auto">
|
<div className="relative z-10 h-screen overflow-y-auto">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { TopBar } from "@/components/ui/topbar";
|
|||||||
|
|
||||||
const API_URL = "";
|
const API_URL = "";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type MenuOptions = {
|
type MenuOptions = {
|
||||||
foetelek: string[];
|
foetelek: string[];
|
||||||
levesek: string[];
|
levesek: string[];
|
||||||
@@ -110,7 +108,7 @@ export default function HomePage() {
|
|||||||
} else {
|
} else {
|
||||||
const ord: Order = msg;
|
const ord: Order = msg;
|
||||||
setOrders((prev) => {
|
setOrders((prev) => {
|
||||||
const filtered = prev.filter(o => o.username !== ord.username);
|
const filtered = prev.filter((o) => o.username !== ord.username);
|
||||||
return [ord, ...filtered];
|
return [ord, ...filtered];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,8 +117,6 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
es.onerror = (err) => {
|
es.onerror = (err) => {
|
||||||
console.error("SSE connection lost ❌", err);
|
console.error("SSE connection lost ❌", err);
|
||||||
es.close();
|
es.close();
|
||||||
@@ -132,8 +128,6 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!username) return null;
|
if (!username) return null;
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
@@ -168,7 +162,6 @@ export default function HomePage() {
|
|||||||
setMain("");
|
setMain("");
|
||||||
setSide("");
|
setSide("");
|
||||||
router.push("/landing");
|
router.push("/landing");
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
setError("Nem sikerült menteni a rendelést.");
|
setError("Nem sikerült menteni a rendelést.");
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
@@ -185,39 +178,54 @@ export default function HomePage() {
|
|||||||
No?... Mit együnk?
|
No?... Mit együnk?
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{menu ? (
|
{menu ? (
|
||||||
<form onSubmit={handleSubmit} className="w-full max-w-6xl space-y-6">
|
<form onSubmit={handleSubmit} className="w-full max-w-6xl space-y-6">
|
||||||
{/* Selection panels */}
|
{/* Selection panels */}
|
||||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||||
<div className="flex flex-col w-full md:w-1/3">
|
<div className="flex flex-col w-full md:w-1/3">
|
||||||
<h2 className="text-center text-2xl sm:text-3xl text-white mb-2">Levesek</h2>
|
<h2 className="text-center text-2xl sm:text-3xl text-white mb-2">
|
||||||
|
Levesek
|
||||||
|
</h2>
|
||||||
<Card className="glass-panel flex flex-col">
|
<Card className="glass-panel flex flex-col">
|
||||||
<CardContent className="pb-1 pt-5">
|
<CardContent className="pb-1 pt-5">
|
||||||
<FoodList items={menu.levesek} onSelect={setSoup} selected={soup} />
|
<FoodList
|
||||||
|
items={menu.levesek}
|
||||||
|
onSelect={setSoup}
|
||||||
|
selected={soup}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col w-full md:w-1/3">
|
<div className="flex flex-col w-full md:w-1/3">
|
||||||
<h2 className="text-center text-2xl sm:text-3xl text-white mb-2">Főételek</h2>
|
<h2 className="text-center text-2xl sm:text-3xl text-white mb-2">
|
||||||
|
Főételek
|
||||||
|
</h2>
|
||||||
<Card className="glass-panel flex flex-col">
|
<Card className="glass-panel flex flex-col">
|
||||||
<CardContent className="pb-1 pt-5">
|
<CardContent className="pb-1 pt-5">
|
||||||
<FoodList items={menu.foetelek} onSelect={setMain} selected={main} />
|
<FoodList
|
||||||
|
items={menu.foetelek}
|
||||||
|
onSelect={setMain}
|
||||||
|
selected={main}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex flex-col w-full md:w-1/3">
|
<div className="flex flex-col w-full md:w-1/3">
|
||||||
<h2 className="text-center text-2xl sm:text-3xl text-white mb-2">Köretek</h2>
|
<h2 className="text-center text-2xl sm:text-3xl text-white mb-2">
|
||||||
|
Köretek
|
||||||
|
</h2>
|
||||||
<Card className="glass-panel flex flex-col">
|
<Card className="glass-panel flex flex-col">
|
||||||
<CardContent className="pb-1 pt-5">
|
<CardContent className="pb-1 pt-5">
|
||||||
<FoodList items={menu.koretek} onSelect={setSide} selected={side} />
|
<FoodList
|
||||||
|
items={menu.koretek}
|
||||||
|
onSelect={setSide}
|
||||||
|
selected={side}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* My Choice */}
|
{/* My Choice */}
|
||||||
@@ -229,14 +237,25 @@ export default function HomePage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-white text-center space-y-2">
|
<CardContent className="text-white text-center space-y-2">
|
||||||
{soup && <p><strong>Leves:</strong> {soup}</p>}
|
{soup && (
|
||||||
|
<p>
|
||||||
|
<strong>Leves:</strong> {soup}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{(main || side) && (
|
{(main || side) && (
|
||||||
<p><strong>Második:</strong> {[main, side].filter(Boolean).join(", ")}</p>
|
<p>
|
||||||
|
<strong>Második:</strong>{" "}
|
||||||
|
{[main, side].filter(Boolean).join(", ")}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{!soup && !main && !side && <p>–</p>}
|
{!soup && !main && !side && <p>–</p>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardContent className="flex flex-col items-center w-full pt-3 pb-4">
|
<CardContent className="flex flex-col items-center w-full pt-3 pb-4">
|
||||||
<Button type="submit" variant="food" className="w-full sm:w-1/3 lg:w-1/3">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="food"
|
||||||
|
className="w-full sm:w-1/3 lg:w-1/3"
|
||||||
|
>
|
||||||
Mehet!
|
Mehet!
|
||||||
</Button>
|
</Button>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -252,7 +271,6 @@ export default function HomePage() {
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-white">Étlap betöltése…</p>
|
<p className="text-white">Étlap betöltése…</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user