initial commit
BIN
backend/app.db
Normal file
142
backend/db.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func database() *sql.DB {
|
||||||
|
db, err := sql.Open("sqlite", "./app.db")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default menu items if empty
|
||||||
|
row := db.QueryRow("SELECT COUNT(*) FROM menu_items")
|
||||||
|
var count int
|
||||||
|
_ = row.Scan(&count)
|
||||||
|
if count == 0 {
|
||||||
|
log.Println("Seeding default menu_items…")
|
||||||
|
defaults := []struct {
|
||||||
|
category string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"soup", "Erőleves"},
|
||||||
|
{"soup", "Dörgicsei Csibeleves"},
|
||||||
|
{"soup", "Újházi Tyúkhúsleves"},
|
||||||
|
{"soup", "Jókai Bableves"},
|
||||||
|
{"soup", "Tárkonyos Raguleves"},
|
||||||
|
{"soup", "Frankfurti Leves"},
|
||||||
|
{"soup", "Tavaszi Zöldségleves"},
|
||||||
|
{"soup", "Fokhagyma Krémleves"},
|
||||||
|
|
||||||
|
{"main", "Babragus Csülök"},
|
||||||
|
{"main", "Rántott Csirkemell"},
|
||||||
|
{"main", "Rántott Gomba és Rántott Camembert"},
|
||||||
|
{"main", "Cordon Bleu"},
|
||||||
|
{"main", "Cigánypecsenye Kakastaréjjal"},
|
||||||
|
{"main", "Mozzarellás-Paradicsomos Csirkemell Roston"},
|
||||||
|
{"main", "Kemencés Fokhagymás Csülökszeletek"},
|
||||||
|
{"main", "Holstein Szelet"},
|
||||||
|
{"main", "Jalapeños Sajtgolyók"},
|
||||||
|
{"main", "Cornflakes Bundában Sült Csirkemell"},
|
||||||
|
{"main", "Baconköntösben Pirított Csirkemáj"},
|
||||||
|
|
||||||
|
{"side", "Friss Saláta Kapros Joghurtos Öntettel"},
|
||||||
|
{"side", "Párolt Rizs"},
|
||||||
|
{"side", "Fűszeres Steakburgonya"},
|
||||||
|
{"side", "Grill Zöldség"},
|
||||||
|
{"side", "Lilahagymás Kukoricasaláta"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range defaults {
|
||||||
|
_, err := db.Exec("INSERT INTO menu_items (category, name) VALUES (?, ?)", d.category, d.name)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error seeding menu item:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create users table
|
||||||
|
_, 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 {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create settings table
|
||||||
|
_, 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
|
||||||
|
_, err = db.Exec(`INSERT OR IGNORE INTO settings (key, value) VALUES ('finalize_time', '10:30')`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS menu_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
24
backend/go.mod
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module menu
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/cors v1.2.2
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
modernc.org/sqlite v1.39.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
|
modernc.org/libc v1.66.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
55
backend/go.sum
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
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/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/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
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/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
|
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||||
|
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
984
backend/main.go
Normal file
@@ -0,0 +1,984 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 App struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Broker *Broker
|
||||||
|
FinalizeUpdate chan struct{} // notify scheduler to reload time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Broker struct {
|
||||||
|
clients map[chan []byte]bool
|
||||||
|
add chan chan []byte
|
||||||
|
remove chan chan []byte
|
||||||
|
broadcast chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var jwtSecret = []byte("supersecretkey") // TODO: move to env variable
|
||||||
|
|
||||||
|
func generateToken(userID int, username string) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": userID,
|
||||||
|
"username": username,
|
||||||
|
"exp": time.Now().Add(2 * time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(jwtSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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) 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := app.DB.Query("SELECT id, username, role FROM users ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []map[string]any
|
||||||
|
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) 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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// main.go
|
||||||
|
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) 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) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST new finalize time
|
||||||
|
func (app *App) handleSetFinalizeTime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
http.Error(w, "bad request", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = app.DB.Exec(`INSERT OR REPLACE INTO settings (key,value) VALUES ('finalize_time', ?)`, in.Time)
|
||||||
|
|
||||||
|
// notify scheduler
|
||||||
|
select {
|
||||||
|
case app.FinalizeUpdate <- struct{}{}:
|
||||||
|
default: // don’t block if already queued
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST trigger finalize now
|
||||||
|
func (app *App) handleFinalizeNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := finalizeOrders(app); err != nil {
|
||||||
|
http.Error(w, "failed", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "done"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Next finalize scheduled at", next)
|
||||||
|
|
||||||
|
timer := time.NewTimer(time.Until(next))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
if err := finalizeOrders(app); err != nil {
|
||||||
|
log.Println("daily finalize failed:", err)
|
||||||
|
} else {
|
||||||
|
log.Println("orders finalized at", time.Now().In(loc))
|
||||||
|
}
|
||||||
|
case <-app.FinalizeUpdate:
|
||||||
|
log.Println("Finalize time updated, recalculating…")
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSummary(orders []Order) {
|
||||||
|
// TODO: replace with email or message
|
||||||
|
log.Println("Daily summary:")
|
||||||
|
for _, o := range orders {
|
||||||
|
log.Printf("%s: %s, %s, %s", o.Username, o.Soup, o.Main, o.Side)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func server(app *App) *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: []string{"*"},
|
||||||
|
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.Get("/users", app.handleGetUsers)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return configured server
|
||||||
|
return &http.Server{
|
||||||
|
Addr: "0.0.0.0:7153",
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
db := database()
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
DB: db,
|
||||||
|
Broker: newBroker(),
|
||||||
|
FinalizeUpdate: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create server
|
||||||
|
srv := server(app)
|
||||||
|
|
||||||
|
log.Println("Server listening on", srv.Addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go startDailyCleanup(app)
|
||||||
|
|
||||||
|
}
|
||||||
BIN
backend/sqlite3.exe
Normal file
41
feedmee/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
feedmee/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
22
feedmee/components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "stone",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
25
feedmee/eslint.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"node_modules/**",
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
16
feedmee/next.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
compress: false,
|
||||||
|
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev', '172.17.96.1', '192.168.1.135', '0.0.0.0'],
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: "http://127.0.0.1:7153/api/:path*",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6942
feedmee/package-lock.json
generated
Normal file
36
feedmee/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "feedmee",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build --turbopack",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.544.0",
|
||||||
|
"next": "15.5.4",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.5.4",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
feedmee/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
feedmee/public/burger.jpg
Normal file
|
After Width: | Height: | Size: 695 KiB |
1
feedmee/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
feedmee/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
feedmee/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
feedmee/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
feedmee/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
5
feedmee/src/app/[...notfound]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function CatchAll() {
|
||||||
|
redirect("/landing");
|
||||||
|
}
|
||||||
544
feedmee/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { TopBar } from "@/components/ui/topbar";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const API_URL = "";
|
||||||
|
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Order = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
soup?: string;
|
||||||
|
main: string;
|
||||||
|
side: string;
|
||||||
|
created_at: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
name: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [editing, setEditing] = useState<number | null>(null);
|
||||||
|
const [newUsername, setNewUsername] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [newRole, setNewRole] = useState<number | null>(null);
|
||||||
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
const [selectedOrders, setSelectedOrders] = useState<number[]>([]);
|
||||||
|
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
|
||||||
|
const [newItem, setNewItem] = useState("");
|
||||||
|
const [newCategory, setNewCategory] = useState("main");
|
||||||
|
const [editingItem, setEditingItem] = useState<MenuItem | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const [finalizeTime, setFinalizeTime] = useState("10:30");
|
||||||
|
const auth = (): Record<string, string> => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/admin/finalize/time`, { headers: auth() })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setFinalizeTime(data.time));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
router.push("/auth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`${API_URL}/api/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.role || data.role > 1) {
|
||||||
|
router.push("/landing");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => router.push("/auth"));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/admin/users`, { headers: auth() })
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(setUsers)
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshOrders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/admin/menu`, { headers: auth() })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data: MenuItem[]) => setMenuItems(data || []))
|
||||||
|
.catch(() => setMenuItems([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function refreshOrders() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/admin/orders`, { headers: auth() });
|
||||||
|
if (res.ok) {
|
||||||
|
const data: Order[] = await res.json();
|
||||||
|
setOrders(data || []);
|
||||||
|
} else {
|
||||||
|
setOrders([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOrders([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMenu() {
|
||||||
|
const res = await fetch(`${API_URL}/api/admin/menu`, { headers: auth() });
|
||||||
|
const data = await res.json();
|
||||||
|
setMenuItems(data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMenuItem() {
|
||||||
|
await fetch(`${API_URL}/api/admin/menu`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ action: "add", category: newCategory, name: newItem }),
|
||||||
|
});
|
||||||
|
setNewItem("");
|
||||||
|
await refreshMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMenuItem() {
|
||||||
|
if (!editingItem) return;
|
||||||
|
await fetch(`${API_URL}/api/admin/menu`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ action: "update", id: editingItem.id, name: editingItem.name }),
|
||||||
|
});
|
||||||
|
setEditingItem(null);
|
||||||
|
await refreshMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMenuItem(id: number) {
|
||||||
|
await fetch(`${API_URL}/api/admin/menu`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ action: "delete", id }),
|
||||||
|
});
|
||||||
|
await refreshMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id: number) {
|
||||||
|
if (!confirm("Biztos törlöd a felhasználót?")) return;
|
||||||
|
await fetch(`${API_URL}/api/admin/users/${id}`, { method: "DELETE", headers: auth() });
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser(id: number) {
|
||||||
|
const body: any = {};
|
||||||
|
if (newUsername) body.username = newUsername;
|
||||||
|
if (newPassword) body.password = newPassword;
|
||||||
|
if (newRole !== null) body.role = newRole;
|
||||||
|
|
||||||
|
await fetch(`${API_URL}/api/admin/users/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) =>
|
||||||
|
u.id === id ? { ...u, username: body.username || u.username } : u
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) =>
|
||||||
|
u.id === id
|
||||||
|
? {
|
||||||
|
...u,
|
||||||
|
username: body.username ?? u.username,
|
||||||
|
role: body.role ?? u.role,
|
||||||
|
}
|
||||||
|
: u
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setEditing(null);
|
||||||
|
setNewUsername("");
|
||||||
|
setNewPassword("");
|
||||||
|
setNewRole(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id: number) {
|
||||||
|
setSelectedOrders((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatusSelected(status: string) {
|
||||||
|
for (const id of selectedOrders) {
|
||||||
|
await fetch(`${API_URL}/api/admin/orders/${id}/status`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedOrders([]);
|
||||||
|
await refreshOrders(); // always refresh after
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
await Promise.all(
|
||||||
|
selectedOrders.map(async (id) => {
|
||||||
|
await fetch(`${API_URL}/api/admin/orders/${id}/status`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: "history" }),
|
||||||
|
});
|
||||||
|
await fetch(`${API_URL}/api/admin/orders/${id}`, { method: "DELETE", headers: auth() });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const res = await fetch(`${API_URL}/api/admin/orders`);
|
||||||
|
setSelectedOrders([]);
|
||||||
|
await refreshOrders();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFinalizeTime() {
|
||||||
|
await fetch(`${API_URL}/api/admin/finalize/time`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ time: finalizeTime }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerFinalizeNow() {
|
||||||
|
await fetch(`${API_URL}/api/admin/finalize/now`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: auth(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex flex-col min-h-screen items-center p-4 pb-16 space-y-6">
|
||||||
|
<TopBar />
|
||||||
|
<div className="h-10" />
|
||||||
|
|
||||||
|
<h1 className="text-white text-3xl sm:text-4xl font-bold text-center">
|
||||||
|
Users
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="w-full max-w-3xl">
|
||||||
|
<div className="glass-panel max-h-[400px] overflow-y-auto space-y-4 p-4">
|
||||||
|
{users.length > 0 ? (
|
||||||
|
users.map((u) => (
|
||||||
|
<Card
|
||||||
|
key={u.id}
|
||||||
|
className="glass-panel flex flex-col sm:flex-row sm:items-center sm:justify-between p-4 gap-3"
|
||||||
|
>
|
||||||
|
{editing === u.id ? (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 w-full items-center justify-between">
|
||||||
|
{/* Keep current username visible */}
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{u.username} <span className="text-sm text-white/60">(role: {u.role})</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Inputs + buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="New Username"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
|
className="bg-white/70 text-black sm:w-40 border-white"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="New Password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="bg-white/70 text-black sm:w-40 border-white"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Role"
|
||||||
|
value={newRole !== null ? newRole : ""}
|
||||||
|
onChange={(e) => setNewRole(Number(e.target.value))}
|
||||||
|
className="bg-white/70 text-black sm:w-20 border-white"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateUser(u.id)}
|
||||||
|
className="bg-black/35 hover:bg-green-700/35 px-3"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="bg-black/35 hover:bg-blue-700/35 px-3"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{u.username} <span className="text-sm text-white/60">(role: {u.role})</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing(u.id)}
|
||||||
|
className="bg-black/35 hover:bg-blue-700/35 px-3"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteUser(u.id)}
|
||||||
|
className="bg-red-600 hover:bg-red-700 px-3"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-white/70 text-center">No registered users.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orders panel */}
|
||||||
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
||||||
|
Orders
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="glass-panel w-full max-w-3xl">
|
||||||
|
<div className="max-h-[400px] overflow-y-auto space-y-2 p-4">
|
||||||
|
{orders.length > 0 ? (
|
||||||
|
orders.map((o) => (
|
||||||
|
<Card
|
||||||
|
key={o.id}
|
||||||
|
onClick={() => toggleSelect(o.id)}
|
||||||
|
className={`glass-panel p-3 cursor-pointer ${
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<strong>{o.username}:</strong>{" "}
|
||||||
|
{o.soup && o.soup.trim() !== "" ? `${o.soup}, ` : ""}
|
||||||
|
{o.main}, {o.side}
|
||||||
|
<span
|
||||||
|
className={`ml-2 px-2 py-0.5 rounded text-xs ${
|
||||||
|
o.status === "active"
|
||||||
|
? "bg-green-600/70"
|
||||||
|
: o.status === "history"
|
||||||
|
? "bg-gray-600/70"
|
||||||
|
: "bg-red-600/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-white/60">
|
||||||
|
{new Date(o.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-white/70 text-center">No orders to display.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-3 justify-center mt-4 pb-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={refreshOrders}
|
||||||
|
className="text-white hover:bg-blue-700/40 rounded-full p-2"
|
||||||
|
title="Frissítés"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-green-600 hover:bg-green-700 px-4"
|
||||||
|
onClick={() => updateStatusSelected("active")}
|
||||||
|
>
|
||||||
|
Set Active
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 px-4"
|
||||||
|
onClick={() => updateStatusSelected("history")}
|
||||||
|
>
|
||||||
|
Set History
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-red-600 hover:bg-red-700 px-4"
|
||||||
|
onClick={deleteSelected}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu Management Panel */}
|
||||||
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
||||||
|
Menu Editor
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="glass-panel w-full max-w-6xl p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{["soup", "main", "side"].map((cat) => (
|
||||||
|
<div key={cat} className="flex flex-col">
|
||||||
|
<h3 className="text-center text-xl font-semibold text-white mb-2">
|
||||||
|
{cat === "soup" ? "Soups" : cat === "main" ? "Main courses" : "Sides"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto space-y-2">
|
||||||
|
{menuItems.filter((i) => i.category === cat).map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
className="glass-panel flex flex-col lg:flex-row md:items-center sm:justify-between p-2 gap-2"
|
||||||
|
>
|
||||||
|
{editingItem?.id === item.id ? (
|
||||||
|
<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
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
feedmee/src/app/auth/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/${mode}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text();
|
||||||
|
throw new Error(msg || "Something went wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem("token", data.token);
|
||||||
|
localStorage.setItem("username", data.username);
|
||||||
|
router.push("/landing");
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message ?? "Failed to authenticate");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex min-h-screen items-center justify-center p-6">
|
||||||
|
|
||||||
|
{/* Foreground content */}
|
||||||
|
<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">
|
||||||
|
FeedMee
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-xl font-semibold text-white">
|
||||||
|
{mode === "login" ? "Login" : "Register"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="username" className="text-white">
|
||||||
|
Username
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
className="bg-white/60 text-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password" className="text-white">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="bg-white/60 text-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="btn-brand w-full" disabled={loading}>
|
||||||
|
{loading
|
||||||
|
? "Please wait…"
|
||||||
|
: mode === "login"
|
||||||
|
? "Login"
|
||||||
|
: "Register"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-3 text-sm text-red-400 text-center" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-center text-white">
|
||||||
|
{mode === "login" ? (
|
||||||
|
<>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-blue-300 hover:underline"
|
||||||
|
onClick={() => setMode("register")}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-blue-300 hover:underline"
|
||||||
|
onClick={() => setMode("login")}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
feedmee/src/app/debug/StreamDebugger.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function StreamDebugger() {
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const log = (msg: string) => {
|
||||||
|
setLogs((prev) => [...prev, msg]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = "http://192.168.1.135:3000/api/orders/stream";
|
||||||
|
log(`Opening EventSource to ${url}`);
|
||||||
|
|
||||||
|
// Use fetch once to peek at headers
|
||||||
|
fetch(url, { method: "GET" })
|
||||||
|
.then((res) => {
|
||||||
|
log("=== Response headers ===");
|
||||||
|
res.headers.forEach((val, key) => log(`${key}: ${val}`));
|
||||||
|
})
|
||||||
|
.catch((err) => log(`Failed to fetch headers: ${err}`));
|
||||||
|
|
||||||
|
const es = new EventSource(url);
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
log("✅ SSE connection opened");
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
log(`📩 message: ${e.data}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = (err) => {
|
||||||
|
log(`❌ SSE error: ${JSON.stringify(err)}`);
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Catch all custom events (in case server sends named events)
|
||||||
|
es.addEventListener("ping", (e) => {
|
||||||
|
log(`📡 ping event: ${(e as MessageEvent).data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
log("Closing SSE connection…");
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-black text-green-400 font-mono text-xs space-y-1 max-h-[80vh] overflow-y-auto">
|
||||||
|
{logs.map((m, i) => (
|
||||||
|
<div key={i}>{m}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
feedmee/src/app/debug/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import StreamDebugger from "./StreamDebugger";
|
||||||
|
|
||||||
|
export default function DebugPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||||
|
<StreamDebugger />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
feedmee/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
185
feedmee/src/app/globals.css
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--primary: oklch(0.216 0.006 56.043);
|
||||||
|
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--secondary: oklch(0.97 0.001 106.424);
|
||||||
|
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--muted: oklch(0.97 0.001 106.424);
|
||||||
|
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||||
|
--accent: oklch(0.97 0.001 106.424);
|
||||||
|
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.923 0.003 48.717);
|
||||||
|
--input: oklch(0.923 0.003 48.717);
|
||||||
|
--ring: oklch(0.709 0.01 56.259);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--sidebar-primary: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||||
|
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||||
|
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.147 0.004 49.25);
|
||||||
|
--foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--card: oklch(0.216 0.006 56.043);
|
||||||
|
--card-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--popover: oklch(0.216 0.006 56.043);
|
||||||
|
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--primary: oklch(0.923 0.003 48.717);
|
||||||
|
--primary-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--secondary: oklch(0.268 0.007 34.298);
|
||||||
|
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--muted: oklch(0.268 0.007 34.298);
|
||||||
|
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||||
|
--accent: oklch(0.268 0.007 34.298);
|
||||||
|
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.553 0.013 58.071);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Custom global styles --- */
|
||||||
|
@layer components {
|
||||||
|
/* Smokey glass panel for cards and panels */
|
||||||
|
.glass-panel {
|
||||||
|
@apply bg-white/15 backdrop-blur-md border border-white/15 shadow-2xl shadow-black/40 text-white;
|
||||||
|
}
|
||||||
|
.glass-button {
|
||||||
|
@apply bg-amber-500/75 backdrop-blur-sm border border-white/30
|
||||||
|
text-white rounded-lg px-6 py-2
|
||||||
|
transition hover:shadow-lg
|
||||||
|
w-full sm:w-1/2 lg:w-1/3 hover:bg-orange-600;
|
||||||
|
}
|
||||||
|
.app-background {
|
||||||
|
position: fixed; /* stays put */
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1; /* behind everything */
|
||||||
|
background-image: url("/burger.jpg");
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
pointer-events: none; /* make it untouchable */
|
||||||
|
touch-action: none; /* block drag gestures */
|
||||||
|
}
|
||||||
|
.app-background::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.35); /* dark overlay */
|
||||||
|
}
|
||||||
|
.food-option {
|
||||||
|
@apply bg-white/5 text-white text-lg hover:bg-orange-500/60;
|
||||||
|
}
|
||||||
|
.food-option-selected {
|
||||||
|
@apply bg-orange-500/75 text-white shadow-xl text-lg;
|
||||||
|
box-shadow: 0 0 5px theme('colors.orange.400');
|
||||||
|
text-shadow: 0 0 5px;
|
||||||
|
}
|
||||||
|
/* Hide scrollbars but keep scroll functionality */
|
||||||
|
.hide-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Ensure html and body always fill viewport */
|
||||||
|
body {
|
||||||
|
background-color: black;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
186
feedmee/src/app/landing/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { TopBar } from "@/components/ui/topbar";
|
||||||
|
|
||||||
|
|
||||||
|
const API_URL = "";
|
||||||
|
|
||||||
|
type Order = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
soup?: string;
|
||||||
|
main: string;
|
||||||
|
side: string;
|
||||||
|
created_at: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
const router = useRouter();
|
||||||
|
const [finalizeTime, setFinalizeTime] = useState("10:30");
|
||||||
|
|
||||||
|
// Load username + initial active orders
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = localStorage.getItem("username");
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
if (!storedUser || !token) {
|
||||||
|
router.push("/auth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsername(storedUser);
|
||||||
|
|
||||||
|
fetch(`${API_URL}/api/orders`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data: Order[]) => setOrders(data || []))
|
||||||
|
.catch(() => setOrders([]));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// Live updates via SSE
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource(`${API_URL}/api/orders/stream`);
|
||||||
|
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
if (!e.data || e.data.startsWith(":")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
|
||||||
|
if (msg.event === "deleted") {
|
||||||
|
setOrders((prev) => prev.filter((o) => o.id !== Number(msg.id)));
|
||||||
|
} else if (msg.event === "updated") {
|
||||||
|
setOrders((prev) =>
|
||||||
|
prev.map((o) =>
|
||||||
|
o.id === Number(msg.id) ? { ...o, status: msg.status } : o
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const ord: Order = msg;
|
||||||
|
setOrders((prev) => {
|
||||||
|
const filtered = prev.filter((o) => o.id !== ord.id);
|
||||||
|
return [ord, ...filtered];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to parse SSE data:", e.data, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => es.close();
|
||||||
|
return () => es.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/finalize/time`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setFinalizeTime(data.time))
|
||||||
|
.catch(() => setFinalizeTime("10:30"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex flex-col min-h-screen items-center p-6 space-y-8">
|
||||||
|
<TopBar />
|
||||||
|
<div className="h-16" />
|
||||||
|
|
||||||
|
{/* My orders */}
|
||||||
|
{orders.some((o) => o.username === username) && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-4">
|
||||||
|
Rendeléseim
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{orders
|
||||||
|
.filter((o) => o.username === username)
|
||||||
|
.map((o) => (
|
||||||
|
<Card
|
||||||
|
className="glass-panel w-full max-w-3xl text-center"
|
||||||
|
key={o.id}
|
||||||
|
>
|
||||||
|
<CardContent className="text-white py-4">
|
||||||
|
<p>
|
||||||
|
{o.soup && o.soup.trim() !== "" ? `${o.soup}, ` : ""}
|
||||||
|
{o.main}, {o.side}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
{new Date(o.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="flex justify-center pb-4">
|
||||||
|
<Button
|
||||||
|
variant="food"
|
||||||
|
onClick={async () => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
await fetch(`${API_URL}/api/orders/${o.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setOrders((prev) =>
|
||||||
|
prev.filter((ord) => ord.id !== o.id)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="sm:w-1/2 lg:w-1/3 bg-gray-900/55 hover:bg-white/35"
|
||||||
|
>
|
||||||
|
Törlés
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* If no orders, show message */}
|
||||||
|
{!orders.some((o) => o.username === username) && (
|
||||||
|
<p className="text-white/70">Ma még nem rendeltél.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* New order button */}
|
||||||
|
|
||||||
|
<text className="text-2xl text-white/80 text-center mt-2 ">
|
||||||
|
A rendeléseket minden nap {finalizeTime}-kor zárjuk le. Addig tudsz választani!
|
||||||
|
</text>
|
||||||
|
<Button
|
||||||
|
variant="food"
|
||||||
|
onClick={() => router.push("/menu")}
|
||||||
|
className="sm:w-1/2 lg:w-1/3"
|
||||||
|
>
|
||||||
|
Új rendelés
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Global orders */}
|
||||||
|
<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">
|
||||||
|
Minden rendelés
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Card className="glass-panel w-full max-w-3xl pt-2 pb-2">
|
||||||
|
<CardContent className="text-white space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{orders.length > 0 ? (
|
||||||
|
orders.map((o) => (
|
||||||
|
<p key={o.id} className="text-center">
|
||||||
|
<strong>{o.username}:</strong>{" "}
|
||||||
|
{o.soup && o.soup.trim() !== "" ? `${o.soup}, ` : ""}
|
||||||
|
{o.main}, {o.side}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
|
||||||
|
<p className="text-center text-white/70">Még senki sem rendelt :(</p>
|
||||||
|
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
feedmee/src/app/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
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 = {
|
||||||
|
title: "FeedMe",
|
||||||
|
description: "Food ordering app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="relative text-white">
|
||||||
|
<div className="app-background"/>
|
||||||
|
<div className="relative z-10 h-screen overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
feedmee/src/app/menu/page.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, FormEvent } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { TopBar } from "@/components/ui/topbar";
|
||||||
|
|
||||||
|
const API_URL = "";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type MenuOptions = {
|
||||||
|
foetelek: string[];
|
||||||
|
levesek: string[];
|
||||||
|
koretek: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Order = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
soup?: string;
|
||||||
|
main: string;
|
||||||
|
side: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FoodList({
|
||||||
|
items,
|
||||||
|
onSelect,
|
||||||
|
selected,
|
||||||
|
}: {
|
||||||
|
items: string[];
|
||||||
|
onSelect: (item: string) => void;
|
||||||
|
selected: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-2 pb-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item}
|
||||||
|
onClick={() => onSelect(selected === item ? "" : item)}
|
||||||
|
className={`p-3 text-center cursor-pointer transition ${
|
||||||
|
selected === item ? "food-option-selected" : "food-option"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [menu, setMenu] = useState<MenuOptions | null>(null);
|
||||||
|
const [main, setMain] = useState("");
|
||||||
|
const [soup, setSoup] = useState("");
|
||||||
|
const [side, setSide] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
|
||||||
|
// Fetch menu options
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = localStorage.getItem("username");
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
if (storedUser && token) {
|
||||||
|
setUsername(storedUser);
|
||||||
|
fetch(`${API_URL}/api/options`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setMenu(data))
|
||||||
|
.catch((err) => console.error("Failed to load options", err));
|
||||||
|
} else {
|
||||||
|
router.push("/auth");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// Fetch existing orders on load
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/orders`)
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data) => setOrders(data || []))
|
||||||
|
.catch(() => setOrders([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to live order updates (SSE)
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("Opening SSE connection…");
|
||||||
|
const es = new EventSource(`${API_URL}/api/orders/stream`);
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
console.log("SSE connection established ✅");
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
// console.log("SSE message received:", e.data);
|
||||||
|
|
||||||
|
if (!e.data || e.data.startsWith(":")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
|
||||||
|
if (msg.event === "deleted") {
|
||||||
|
setOrders((prev) => prev.filter((o) => o.id !== Number(msg.id)));
|
||||||
|
} else {
|
||||||
|
const ord: Order = msg;
|
||||||
|
setOrders((prev) => {
|
||||||
|
const filtered = prev.filter(o => o.username !== ord.username);
|
||||||
|
return [ord, ...filtered];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to parse SSE data:", e.data, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
es.onerror = (err) => {
|
||||||
|
console.error("SSE connection lost ❌", err);
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("Closing SSE connection…");
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!main || !side) {
|
||||||
|
setError("Kérlek válassz legalább egy főételt és egy köretet!");
|
||||||
|
setSuccess(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
router.push("/auth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/api/orders`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ main, soup, side }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess("A rendelés sikeresen mentve!");
|
||||||
|
setError(null);
|
||||||
|
setSoup("");
|
||||||
|
setMain("");
|
||||||
|
setSide("");
|
||||||
|
router.push("/landing");
|
||||||
|
|
||||||
|
} else {
|
||||||
|
setError("Nem sikerült menteni a rendelést.");
|
||||||
|
setSuccess(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex flex-col min-h-screen items-center p-4 sm:p-6 space-y-6">
|
||||||
|
<TopBar />
|
||||||
|
<div className="h-8" /> {/* spacer for ribbon */}
|
||||||
|
{/* Header with welcome text */}
|
||||||
|
<div className="w-full max-w-6xl text-center">
|
||||||
|
<h1 className="text-white text-3xl sm:text-4xl font-bold">
|
||||||
|
No?... Mit együnk?
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menu ? (
|
||||||
|
<form onSubmit={handleSubmit} className="w-full max-w-6xl space-y-6">
|
||||||
|
{/* Selection panels */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||||
|
<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>
|
||||||
|
<Card className="glass-panel flex flex-col">
|
||||||
|
<CardContent className="pb-1 pt-5">
|
||||||
|
<FoodList items={menu.levesek} onSelect={setSoup} selected={soup} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<Card className="glass-panel flex flex-col">
|
||||||
|
<CardContent className="pb-1 pt-5">
|
||||||
|
<FoodList items={menu.foetelek} onSelect={setMain} selected={main} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<Card className="glass-panel flex flex-col">
|
||||||
|
<CardContent className="pb-1 pt-5">
|
||||||
|
<FoodList items={menu.koretek} onSelect={setSide} selected={side} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My Choice */}
|
||||||
|
<div className="relative flex flex-col items-center p-6 ">
|
||||||
|
<Card className="glass-panel pt-4 w-full md:w-1/3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center text-2xl sm:text-3xl text-white">
|
||||||
|
Szóval...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-white text-center space-y-2">
|
||||||
|
{soup && <p><strong>Leves:</strong> {soup}</p>}
|
||||||
|
{(main || side) && (
|
||||||
|
<p><strong>Második:</strong> {[main, side].filter(Boolean).join(", ")}</p>
|
||||||
|
)}
|
||||||
|
{!soup && !main && !side && <p>–</p>}
|
||||||
|
</CardContent>
|
||||||
|
<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">
|
||||||
|
Mehet!
|
||||||
|
</Button>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-center text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<p className="mt-2 text-center text-green-400">{success}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="text-white">Étlap betöltése…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
feedmee/src/app/mod/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { TopBar } from "@/components/ui/topbar";
|
||||||
|
|
||||||
|
const API_URL = "";
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
name: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ModPage() {
|
||||||
|
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
|
||||||
|
const [newItem, setNewItem] = useState("");
|
||||||
|
const [newCategory, setNewCategory] = useState("main");
|
||||||
|
const [editingItem, setEditingItem] = useState<MenuItem | null>(null);
|
||||||
|
const [finalizeTime, setFinalizeTime] = useState("10:30");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const auth = (): Record<string, string> => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redirect if not logged in
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
router.push("/auth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`${API_URL}/api/me`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.role || data.role > 50) {
|
||||||
|
router.push("/landing");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => router.push("/auth"));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// Load menu
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/mod/menu`, { headers: auth() })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data: MenuItem[]) => setMenuItems(data || []))
|
||||||
|
.catch(() => setMenuItems([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load finalize time
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/mod/finalize/time`, { headers: auth() })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setFinalizeTime(data.time));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function refreshMenu() {
|
||||||
|
const res = await fetch(`${API_URL}/api/mod/menu`, { headers: auth() });
|
||||||
|
const data = await res.json();
|
||||||
|
setMenuItems(data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMenuItem() {
|
||||||
|
await fetch(`${API_URL}/api/mod/menu`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ action: "add", category: newCategory, name: newItem }),
|
||||||
|
});
|
||||||
|
setNewItem("");
|
||||||
|
await refreshMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMenuItem() {
|
||||||
|
if (!editingItem) return;
|
||||||
|
await fetch(`${API_URL}/api/mod/menu`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ action: "update", id: editingItem.id, name: editingItem.name }),
|
||||||
|
});
|
||||||
|
setEditingItem(null);
|
||||||
|
await refreshMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMenuItem(id: number) {
|
||||||
|
await fetch(`${API_URL}/api/mod/menu`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ action: "delete", id }),
|
||||||
|
});
|
||||||
|
await refreshMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFinalizeTime() {
|
||||||
|
await fetch(`${API_URL}/api/mod/finalize/time`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...auth() },
|
||||||
|
body: JSON.stringify({ time: finalizeTime }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerFinalizeNow() {
|
||||||
|
await fetch(`${API_URL}/api/mod/finalize/now`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: auth(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex flex-col min-h-screen items-center p-4 pb-16 space-y-6">
|
||||||
|
<TopBar />
|
||||||
|
<div className="h-10" />
|
||||||
|
|
||||||
|
{/* Menu Management Panel */}
|
||||||
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
||||||
|
Menü szerkesztő
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="glass-panel w-full max-w-6xl p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{["soup", "main", "side"].map((cat) => (
|
||||||
|
<div key={cat} className="flex flex-col">
|
||||||
|
<h3 className="text-center text-xl font-semibold text-white mb-2">
|
||||||
|
{cat === "soup" ? "Levesek" : cat === "main" ? "Főételek" : "Köretek"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto space-y-2">
|
||||||
|
{menuItems.filter((i) => i.category === cat).map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
className="glass-panel flex flex-col lg:flex-row md:items-center sm:justify-between p-2 gap-2"
|
||||||
|
>
|
||||||
|
{editingItem?.id === item.id ? (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Mentés
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingItem(null)}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 px-3"
|
||||||
|
>
|
||||||
|
Mégse
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Szerk.
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteMenuItem(item.id)}
|
||||||
|
className="bg-red-600 hover:bg-red-700 px-3"
|
||||||
|
>
|
||||||
|
Töröl
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Hozzáad
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Finalization Panel */}
|
||||||
|
<h2 className="text-white text-2xl sm:text-3xl font-semibold text-center mt-8">
|
||||||
|
Rendelési időkorlát
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Időpont Mentése
|
||||||
|
</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>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
feedmee/src/app/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// app/page.tsx
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RootRedirect() {
|
||||||
|
redirect("/landing");
|
||||||
|
}
|
||||||
61
feedmee/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link:
|
||||||
|
"text-primary underline-offset-4 hover:underline",
|
||||||
|
food:
|
||||||
|
"bg-orange-500/65 backdrop-blur-sm border border-white/30 text-white rounded-lg px-6 py-2 transition hover:shadow-lg hover:bg-orange-500/90",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3 w-full",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
feedmee/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"glass-panel rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
21
feedmee/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
feedmee/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
45
feedmee/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
185
feedmee/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
74
feedmee/src/components/ui/topbar.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LogOut, Settings } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const API_URL = "";
|
||||||
|
|
||||||
|
function TopBar() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [role, setRole] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = localStorage.getItem("username");
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
setUsername(storedUser);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
fetch(`${API_URL}/api/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setRole(data.role))
|
||||||
|
.catch(() => setRole(null));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 w-full bg-white/15 backdrop-blur-md border-b border-white/20 flex items-center justify-between px-6 py-3 z-50">
|
||||||
|
{/* Left side (app name) */}
|
||||||
|
<Link href="/landing">
|
||||||
|
<h1 className="text-white text-2xl font-bold cursor-pointer hover:text-white/80">
|
||||||
|
FeedMee
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Right side (username + settings + logout) */}
|
||||||
|
<div className="flex items-center space-x-3 max-w-[50%]">
|
||||||
|
<span className="text-white truncate">{username}</span>
|
||||||
|
|
||||||
|
{/* Settings icon if mod/admin */}
|
||||||
|
{role !== null && role <= 50 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push(role <= 1 ? "/admin" : "/mod")}
|
||||||
|
className="text-white hover:bg-white/75"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("username");
|
||||||
|
router.push("/auth");
|
||||||
|
}}
|
||||||
|
className="text-white hover:bg-white/75"
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TopBar };
|
||||||
6
feedmee/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
19
feedmee/tailwind.config.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
DEFAULT: "#d35400", // dark orange
|
||||||
|
light: "#e67e22", // lighter orange
|
||||||
|
dark: "#a84300", // darker hover
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
27
feedmee/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||