Compare commits

...

46 Commits

Author SHA1 Message Date
e065799edc Jwt token expiration time extended from 2 to 24h
All checks were successful
Build Feedmee Go Backend App / build-and-push (push) Successful in 1m34s
Build Feedmee Next.js Frontend App / build-and-push (push) Successful in 2m27s
2025-10-29 12:44:50 +01:00
d4452bb3b1 Merge pull request 'galt' (#1) from galt into main
All checks were successful
Build Feedmee Go Backend App / build-and-push (push) Successful in 14s
Build Feedmee Next.js Frontend App / build-and-push (push) Successful in 13s
Reviewed-on: #1
2025-10-20 08:06:40 +02:00
dff9d56e1b Debug superadmin password
All checks were successful
Build Feedmee Next.js Frontend App / build-and-push (push) Successful in 14s
Build Feedmee Go Backend App / build-and-push (push) Successful in 57s
2025-10-17 13:03:08 +02:00
37d2ce99bf Fix version and BuildTime injection
All checks were successful
Build Feedmee Next.js Frontend App / build-and-push (push) Successful in 13s
Build Feedmee Go Backend App / build-and-push (push) Successful in 1m27s
2025-10-17 12:50:05 +02:00
b8e5a2841f Add CI to galt branch
Some checks failed
Build Feedmee Next.js Frontend App / build-and-push (push) Successful in 2m31s
Build Feedmee Go Backend App / build-and-push (push) Failing after 12s
2025-10-17 12:44:27 +02:00
232e553c02 Remove Geist and GeistMono font 2025-10-17 12:42:38 +02:00
d936175de8 Fix User listing in admin page 2025-10-17 12:42:14 +02:00
560693633e Refactor backend database connection, migration, seed 2025-10-17 12:41:58 +02:00
176032e54b Change container workdir from /root to /app 2025-10-17 12:41:10 +02:00
d9bbede328 Update node version to 25 2025-10-17 12:40:47 +02:00
390410020f Remove app.db 2025-10-17 12:40:27 +02:00
7849a77acc Add .DS_Store to gitingore 2025-10-17 12:39:44 +02:00
03f92076b5 Top level gitignore 2025-10-17 12:39:32 +02:00
1a84e44351 Add security to local directory setup 2025-10-17 00:10:08 +02:00
975b72e89f Refactor allowedOrigins creation 2025-10-17 00:01:16 +02:00
91c9aba1ab Update to Go 1.25.3 2025-10-16 23:54:59 +02:00
1541f1ca85 Exclude data directory 2025-10-16 23:51:20 +02:00
ab0b102a81 Refactor handlers.NewApp 2025-10-16 23:50:29 +02:00
fd24695764 Setup database and Data directory 2025-10-16 23:48:53 +02:00
d13ea9fc27 Refactored project structure 2025-10-16 22:45:31 +02:00
cf74cce51c Do not track .env files 2025-10-16 22:45:02 +02:00
08bf952988 Env variables added
All checks were successful
Build Feedmee Go Backend App / build-and-push (push) Successful in 1m4s
Build Feedmee Next.js Frontend App / build-and-push (push) Successful in 13s
2025-10-16 15:55:43 +02:00
63c6031d66 Add Backend Dockerfile and workflow
All checks were successful
Build Feedmee Go Backend App / build-and-push (push) Successful in 1m22s
Build Feedmee Next.js Frontend App / build-and-push (push) Successful in 12s
2025-10-16 15:12:54 +02:00
1b85072c3c Rename workflow 2025-10-16 15:12:38 +02:00
a25582e1a1 Change build workflow
All checks were successful
Build Feedmee Next.js Frontend App / build-and-push (push) Successful in 22s
2025-10-16 14:39:41 +02:00
697cae5108 Test Gitea Container Registry login 2
Some checks failed
Build Feedmee Next.js Frontend App / build-and-push (push) Failing after 1m2s
2025-10-16 14:28:16 +02:00
6ecf2fd98b Fix typo
Some checks failed
Build Feedmee Next.js Frontend App / build-and-push (push) Has been cancelled
2025-10-16 14:23:38 +02:00
0d604a1c5b Test Gitea Container Registry login
Some checks failed
Build Feedmee Next.js Frontend App / build-and-push (push) Has been cancelled
2025-10-16 14:23:16 +02:00
372adaa8cf Remove .env files copy
Some checks failed
Build Feedmee Next.js Frontend App / build-and-push (push) Failing after 3m8s
2025-10-16 14:16:12 +02:00
605dc7dd46 Fix Dockerfile name
Some checks failed
Build Feedmee Next.js Frontend App / build-and-push (push) Failing after 15s
2025-10-16 14:14:12 +02:00
b1209ca25d Change tag name
Some checks failed
Build Feedmee Next.js Frontend App / build-and-push (push) Failing after 11s
2025-10-16 14:13:10 +02:00
6778c448c2 Fix typo
Some checks failed
Build Feedmee Next.js Frontend App / build-and-push (push) Failing after 11s
2025-10-16 14:10:40 +02:00
55f07c96cd Added test frontend docker image build workflow
Some checks failed
Build Feeme Next.js Frontend App / build-and-push (push) Failing after 11s
2025-10-16 13:47:46 +02:00
3436874998 Frontend dockerfile - testing1 2025-10-16 13:46:39 +02:00
9e00e4db6c Add standalone option 2025-10-16 13:45:55 +02:00
05e8d039bd Fix formatting 2025-10-16 10:42:26 +02:00
52aab3460d Fix font 2025-10-16 10:24:59 +02:00
ab8bfc0b1c Fix any, fix formatting 2025-10-16 10:00:47 +02:00
6d9e307318 Fix formatting, add types 2025-10-16 10:00:31 +02:00
35c4317925 Add font to app 2025-10-16 10:00:07 +02:00
5392c47452 Fix wrong text tag 2025-10-16 09:27:32 +02:00
5348777ed4 Add comment to database() 2025-10-16 09:22:18 +02:00
20c8981268 Changed go.work 2025-10-16 09:18:44 +02:00
e0d22545fb Added gitignore to backend 2025-10-16 09:17:59 +02:00
4416ce3280 deleted: backend/sqlite3.exe 2025-10-06 15:04:37 +02:00
0e18760e5d Added list of last orders to mod menu 2025-10-06 15:03:22 +02:00
42 changed files with 2187 additions and 1451 deletions

View 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

View 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

View 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

View 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
View File

@@ -0,0 +1 @@
.DS_Store

6
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
go.work*
.env
data/
# misc
.DS_Store
*.pem

33
backend/Dockerfile Normal file
View 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"]

Binary file not shown.

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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
View 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,
})
}

View 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)
}

View 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: // dont 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
View 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
}

View 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
}

View 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()
}
}
}()
}

View 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
View 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(24 * 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
View 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"`
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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
}

View 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
View 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

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,20 @@
{
"name": "FeedMe",
"short_name": "FeedMe",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -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;
@@ -29,10 +26,12 @@ type Order = {
status: string; status: 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() {
@@ -49,41 +48,48 @@ export default function AdminPage() {
const [editingItem, setEditingItem] = useState<MenuItem | null>(null); const [editingItem, setEditingItem] = useState<MenuItem | null>(null);
const router = useRouter(); const router = useRouter();
const [finalizeTime, setFinalizeTime] = useState("10:30"); const [finalizeTime, setFinalizeTime] = useState("10:30");
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(() => {
@@ -92,22 +98,28 @@ 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(() => {
loadLastSummary();
}, []); }, []);
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([]);
} }
} }
@@ -119,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();
@@ -130,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();
@@ -140,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);
@@ -191,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([]);
@@ -210,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([]);
@@ -226,319 +257,377 @@ 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() {
const res = await fetch(`${API_URL}/api/mod/finalize/last`, {
headers: auth(),
});
const data = await res.json();
if (data.status !== "none") setLastSummary(data);
else setLastSummary(null);
}
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();
} }
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 */} {/* Menu Management 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">
Menu Editor Menu Editor
</h2> </h2>
<div className="glass-panel w-full max-w-6xl p-4"> <div className="glass-panel w-full max-w-6xl p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{["soup", "main", "side"].map((cat) => ( {["soup", "main", "side"].map((cat) => (
<div key={cat} className="flex flex-col"> <div key={cat} className="flex flex-col">
<h3 className="text-center text-xl font-semibold text-white mb-2"> <h3 className="text-center text-xl font-semibold text-white mb-2">
{cat === "soup" ? "Soups" : cat === "main" ? "Main courses" : "Sides"} {cat === "soup"
</h3> ? "Soups"
: cat === "main"
? "Main courses"
: "Sides"}
</h3>
<div className="max-h-[300px] overflow-y-auto space-y-2"> <div className="max-h-[300px] overflow-y-auto space-y-2">
{menuItems.filter((i) => i.category === cat).map((item) => ( {menuItems
<Card .filter((i) => i.category === cat)
key={item.id} .map((item) => (
className="glass-panel flex flex-col lg:flex-row md:items-center sm:justify-between p-2 gap-2" <Card
> key={item.id}
{editingItem?.id === item.id ? ( className="glass-panel flex flex-col lg:flex-row md:items-center sm:justify-between p-2 gap-2"
<div className="flex flex-col lg:flex-row gap-2 w-full items-center justify-between">
<Input
value={editingItem.name}
onChange={(e) =>
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="flex flex-col sm:flex-row gap-2 mt-3">
<Input
placeholder={`Új ${cat === "soup" ? "Soup" : cat === "main" ? "Main" : "Side"}`}
value={newCategory === cat ? newItem : ""}
onChange={(e) => {
setNewItem(e.target.value);
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">
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>
); );
} }

View File

@@ -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"

View File

@@ -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;

View File

@@ -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,12 +155,10 @@ 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">
Minden rendelés Aktív rendelések
</h2> </h2>
<Card className="glass-panel w-full max-w-3xl pt-2 pb-2"> <Card className="glass-panel w-full max-w-3xl pt-2 pb-2">
@@ -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">Még senki sem rendelt :(</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,20 +1,12 @@
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",
manifest: "/manifest.json",
icons: {
apple: "/icons/icon-192.png",
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -24,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>
); );

View File

@@ -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>
); );
} }

View File

@@ -15,6 +15,9 @@ type MenuItem = {
name: string name: string
}; };
type FinalizedSummary = { pickup: string[]; kitchen: string[] };
export default function ModPage() { export default function ModPage() {
const [menuItems, setMenuItems] = useState<MenuItem[]>([]); const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
const [newItem, setNewItem] = useState(""); const [newItem, setNewItem] = useState("");
@@ -22,6 +25,8 @@ export default function ModPage() {
const [editingItem, setEditingItem] = useState<MenuItem | null>(null); const [editingItem, setEditingItem] = useState<MenuItem | null>(null);
const [finalizeTime, setFinalizeTime] = useState("10:30"); const [finalizeTime, setFinalizeTime] = useState("10:30");
const router = useRouter(); const router = useRouter();
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");
@@ -61,6 +66,10 @@ export default function ModPage() {
.then((data) => setFinalizeTime(data.time)); .then((data) => setFinalizeTime(data.time));
}, []); }, []);
useEffect(() => {
loadLastSummary();
}, []);
async function refreshMenu() { async function refreshMenu() {
const res = await fetch(`${API_URL}/api/mod/menu`, { headers: auth() }); const res = await fetch(`${API_URL}/api/mod/menu`, { headers: auth() });
const data = await res.json(); const data = await res.json();
@@ -110,8 +119,17 @@ export default function ModPage() {
method: "POST", method: "POST",
headers: auth(), headers: auth(),
}); });
await loadLastSummary();
} }
async function loadLastSummary() {
const res = await fetch(`${API_URL}/api/mod/finalize/last`, { headers: auth() });
const data = await res.json();
if (data.status !== "none") setLastSummary(data);
else setLastSummary(null);
}
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 />
@@ -238,6 +256,39 @@ export default function ModPage() {
</Button> </Button>
</div> </div>
</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">Pickup 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>
<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>
); );
} }