initial commit

This commit is contained in:
2025-10-01 20:49:02 +02:00
parent 62020bdf74
commit 4c732b8f44
42 changed files with 10548 additions and 0 deletions

BIN
backend/app.db Normal file

Binary file not shown.

142
backend/db.go Normal file
View 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
View 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
View 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
View 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: // dont block if already queued
}
writeJSON(w, map[string]string{"status": "ok"})
}
// POST trigger finalize now
func (app *App) handleFinalizeNow(w http.ResponseWriter, r *http.Request) {
if err := finalizeOrders(app); err != nil {
http.Error(w, "failed", 500)
return
}
writeJSON(w, map[string]string{"status": "done"})
}
func (app *App) 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

Binary file not shown.

41
feedmee/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
feedmee/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

BIN
feedmee/public/burger.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

1
feedmee/public/file.svg Normal file
View 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
View 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
View 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

View 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

View 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

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function CatchAll() {
redirect("/landing");
}

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

View 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&apos;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>
);
}

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

185
feedmee/src/app/globals.css Normal file
View 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;
}

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

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

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

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

@@ -0,0 +1,6 @@
// app/page.tsx
import { redirect } from "next/navigation";
export default function RootRedirect() {
redirect("/landing");
}

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

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

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

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

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

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

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

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